diff --git a/.gitignore b/.gitignore index 50756ce4..2741c572 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -target -dist -bin -.idea/workspace.xml -workspace.xml +.project +.classpath +.settings/ *.iml -.idea -out \ No newline at end of file +.idea/ +out/ +bin/ +dist/ +target/ diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 6f31460e..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -ExifTool \ No newline at end of file diff --git a/.idea/ant.xml b/.idea/ant.xml deleted file mode 100644 index f6e673ad..00000000 --- a/.idea/ant.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index f62473b0..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml deleted file mode 100644 index e7bedf33..00000000 --- a/.idea/copyright/profiles_settings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/dictionaries/rush.xml b/.idea/dictionaries/rush.xml deleted file mode 100644 index 5b0c8b5e..00000000 --- a/.idea/dictionaries/rush.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - iptc - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index e206d70d..00000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 56f909bb..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,108 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 3b312839..00000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 83c32674..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 1cd2724b..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/.idea/scopes/scope_settings.xml b/.idea/scopes/scope_settings.xml deleted file mode 100644 index 922003b8..00000000 --- a/.idea/scopes/scope_settings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 275077f8..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/README b/README deleted file mode 100644 index d64caa94..00000000 --- a/README +++ /dev/null @@ -1,5 +0,0 @@ -ExifTool - Enhanced Java Integration for Phil Harvey's ExifTool. - -http://www.thebuzzmedia.com/software/exiftool-enhanced-java-integration-for-exiftool/ - -Forked from https://github.com/thebuzzmedia/exiftool for use with DF Studio (www.dfstudio.com) diff --git a/README.md b/README.md new file mode 100644 index 00000000..ceba901e --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +ExifTool - Enhanced Java Integration for Phil Harvey's ExifTool. +http://www.thebuzzmedia.com/software/exiftool-enhanced-java-integration-for-exiftool/ + + + ================================================= + See ExifTool in action at http://imgscalr.com + ================================================= + + +Changelog +--------- +1.2 (In progress...) + * Added support for the "CreationDate" QuickTime tag to the Tag enum. + * Merged support for WRITING meta tags from Fabien Vauchelles + +1.1 + * Initial public release. + + +License +------- +This library is released under the Apache 2 License. See LICENSE. + + +Description +----------- +This project represents the most robust Java integrations with Phil Harvey's +excellent ExifTool available. + +The goal of this project was to provide such a tight, well designed and performant +integration with ExifTool that any Java developer using the class would have no +idea that they weren't simply calling into a standard Java library while still +being able to leverage the unmatched robustness of ExifTool. + +All concepts of external process launching, management, communication, tag +extraction, value conversion and resource cleanup are abstracted out by this +project and all handled automatically for the caller. + +Even when using ExifTool in "daemon mode" via the -stay_open True command line +argument, this project hides all the details required to make that work, +automatically re-using the daemon process as well as eventually cleaning it up +automatically along with supporting resources after a defined interval of +inactivity so as to avoid resource leaks. + +The set of EXIF tags supported out of the box is based on the EXIF tags supported +by the most popular mobile devices (iPhone, Android, BlackBerry, etc.) as well +as some of the most popular cameras on the market (Canon point and shoot as well +as DSLR). + +And lastly, to ensure that integration with the external ExifTool project is as +robust and seamless as possible, this class also offers extensive pre-condition +checking and error reporting during instantiation and use. + +For example, if you specify that you want to use Feature.STAY_OPEN support, the +ExifTool class will actually check the native ExifTool executable for support +for that feature before allowing the feature to be turned on and report the +problem to the caller along with potential work-arounds if necessary. + +Additionally, all external calls to the process are safely wrapped and reported +with detailed exceptions if problems arise instead of just letting unknown +exceptions bubble up from the unknown system depths to the caller. + +All the exceptions and exceptional scenarios are well-documented in the Javadoc +along with extensive implementation details for anyone wanting to know more about +the project. + + +Example +------- +Usage is straight forward, let's say we wanted to get the GPS coordinates out of +an image: + + File image = // path to some image + ExifTool tool = new ExifTool(); + + Map valueMap = + tool.getImageMeta(image, Tag.GPS_LATITUDE, Tag.GPS_LONGITUDE); + + System.out.println("Lat: " + valueMap.get(Tag.GPS_LATITUDE) + + ", Long: " + valueMap.get(Tag.GPS_LONGITUDE)); + +The fundamentals of use is that you give ExifTool a File handle to the image you +want to query as well as a list of Tags you want to pull values from it for and +it will give you back the results in a Map. + +If you want to use ExifTool in daemon mode, you only change one line: + + ExifTool tool = new ExifTool(Feature.STAY_OPEN); + +and then keep that "tool" reference around and re-use it as necessary. Under the +covers the ExifTool class will re-use the same ExifTool process for all the queries, +usually taking 1/20th or 1/30th the time to complete. + + +Performance +----------- +You can benchmark the performance of this ExifTool library on your machine by +running the Benchmark class under the /test/java repository. + +Here is an example output on my Core2 Duo 3.0Ghz E6850 w/ 12GB of Ram: + +Benchmark [tags=49, images=10, iterations=25] + 250 ExifTool process calls, 12250 total operations. + + [-stay_open False] + Elapsed Time: 97823 ms (97.823 secs) + [-stay_open True] + Elapsed Time: 4049 ms (4.049 secs - 24.159792x faster) + +You can see that utilizing the -stay_open functionality provided in ExifTool +you can realize magnitudes times more performance. + +Also the bigger of a test you run (more iterations) the bigger the performance +margin increases. + + +History +------- +This ExifTool library was incubated within imgscalr as an extension library that +I originally intended to be a simple way to pull the 'Orientation' EXIF flag out +of images in order to service automatic orientation support in imgscalr. After +working on the integration layer for a few days I realized the potential for the +class and the opportunity to provide the best Java integration with ExifTool +available today. + +From there I branched the code into its own project (the one you are looking at) +and continued to work on making the implementation as robust as possible. + +Once the project had been branched, many of the more advanced features like +daemon mode support, automatic resource cleanup thread, most-popular-tags support, +tag value parsing, etc. all became self evident additions to the class to make +usage as easy and seamless as possible for Java developers. + +My goal was ALWAYS to provide a class so well designed and performant that any +Java developer using it, wouldn't even realize they weren't using a Java library. + + +Troubleshooting +--------------- +Below are a few common scenarios you might run into and proposed workarounds for +them. + + * I keep getting UnsupportedFeatureException exceptions when running ExifTool + with Feature.STAY_OPEN support. + + This exception will only be raised when you attempt to use a Feature that + the underlying ExifTool doesn't support. This means you either need to upgrade + your install of ExifTool or skip using the feature. + + + * I downloaded the newest version of ExifTool, but I keep getting + UnsupportedFeatureExceptions. + + What is probably happening is that your host system already had ExifTool + installed and the default EXIF_TOOL_PATH is simply running the command "exiftool" + which executes the one in the system path, not the newest version you may have + just downloaded. + + You can confirm this by typing 'which exiftool' to see which one is getting + launched. You can also point the ExifTool class at the correct version by + setting the "exiftool.path" system property to point at it, e.g.: + java -Dexiftool.path=/path/to/exiftool com.myco.MyApp + + + * Can the ExifTool class support parsing InputStreams instead of File + representations of images? + + No. Phil has mentioned that enabling daemon mode disables the ability to + stream bytes to ExifTool to process for EXIF data (because ExifTool listens + on the same input stream for processing commands and a terminating -execute + sequence, it can't also listen for image byte[] data). + + Because of this and because of the expectation that ExifTool in daemon mode + will be the primary use-case for this class, limited support for InputStream + parsing was designed out of this class. + + + * Do I need to manually call close() to cleanup a daemon ExifTool? + + This is done automatically for you via the cleanup thread the class employs + when a daemon instance of ExifTool is created. Unless you modified the + "exiftool.processCleanupDelay" system property and set it to 0 or less, the + automatic cleanup thread is enabled and will clean up those resources for + you after the specified amount of inactivity. + + If you DID disable the cleanup thread by setting "exiftool.processCleanupDelay" + to 0, then yes, you need to call close() manually when done to cleanup those + resources. + + + * Is it better to manage cleanup myself or let the cleanup thread do it? + + It is better (and more consistent) to let the cleanup thread handle cleanup + for you. You can always adjust the inactivity interval it uses by adjusting + the value for the "exiftool.processCleanupDelay" system property, but by + default the cleanup thread waits for 10 minutes of total inactivity before + cleaning up the resources. That should be good in most cases, but you could + always set that higher to something like an hour or more if you wish. + + If you really want to disable it and manage everything yourself, that is fine. + Just remember to be consistent. + + +## Reference +ExifTool by Phil Harvey - http://www.sno.phy.queensu.ca/~phil/exiftool/ +imgscalr - http://www.thebuzzmedia.com/software/imgscalr-java-image-scaling-library/ + + +## Contact +If you have questions, comments or bug reports for this software please contact +us at: software@thebuzzmedia.com + + +## Maven +- current release: 2.3.2 +- at `http://raisercostin.googlecode.com/svn/maven2/com/thebuzzmedia/exiftool/exiftool-lib/` +- create deliverables: + `mvn install -Prelease -DskipTests=true` +- release in svn: + `svn import -m "release" C:\Users\costin\.m2\repository\com https://raisercostin.googlecode.com/svn/maven2/com --force` +- release in svn subsequent releases: + `svn import -m "release 2.3.1" C:\Users\costin\.m2\repository\com\thebuzzmedia\exiftool\exiftool-lib\2.3.1 https://raisercostin.googlecode.com/svn/maven2/com/thebuzzmedia/exiftool/exiftool-lib/2.3.1 --force` +- release with standard maven process + + mvn release:prepare -Prelease -DskipTests -Darguments="-DskipTests -Prelease" + mvn release:perform -Prelease -DskipTests -Darguments="-DskipTests -Prelease" + +- configure your ~/.m2/settings.xml as + + + + exiftool.releases + deployment + deployment123 + + +- see last release [https://raisercostin.googlecode.com/svn/maven2/com/thebuzzmedia/exiftool/exiftool-lib/](https://raisercostin.googlecode.com/svn/maven2/com/thebuzzmedia/exiftool/exiftool-lib/) + +## Sbt Configuration +build.sbt + + resolvers += "raisercostin" at "https://raisercostin.googlecode.com/svn/maven2" + libraryDependencies += "com.thebuzzmedia.exiftool" % "exiftool-lib" % "2.3.2" + +## Samples + + ExifTool tool = new ExifTool(); + File imageFile = new File("/path/to/image.jpg"); + + //Read Metadata + Map metadata = tool.getImageMeta(imageFile, + ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.values()); + String cameraModel = metadata.get(ExifTool.Tag.MODEL); + + ExifTool.Tag tag = ExifTool.Tag.IMAGE_HEIGHT; + int imageWidth = tag.parseValue(metadata.get(tag)); + + //Write Metadata + Map data = new HashMap(); + data.put(ExifTool.MwgTag.KEYWORDS, new String[]{"portrait", "nature", "flower"}); + tool.writeMetadata(imageFile, data); + +See the included unit tests for more examples. \ No newline at end of file diff --git a/build.xml b/build.xml new file mode 100644 index 00000000..0cd93394 --- /dev/null +++ b/build.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design.png b/design.png new file mode 100644 index 00000000..be081f6b Binary files /dev/null and b/design.png differ diff --git a/design.ucls b/design.ucls new file mode 100644 index 00000000..61161142 --- /dev/null +++ b/design.ucls @@ -0,0 +1,564 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index d7b8bf6f..73d5707d 100644 --- a/pom.xml +++ b/pom.xml @@ -1,59 +1,214 @@ - - 4.0.0 - - - com.thebuzzmedia.exiftool - exiftool-lib - jar - 2.2.0 - https://github.com/thebuzzmedia/exiftool - - - - Riyad Kalla - software@thebuzzmedia.com - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.0 - - 1.5 - 1.5 - - - - - - - - org.slf4j - slf4j-api - 1.7.5 - compile - - - - junit - junit - 4.11 - test - - - - - org.slf4j - slf4j-simple - 1.7.5 - test - - - + + + 4.0.0 + + org.raisercostin.utils + maven-defaults + 6 + + + org.raisercostin + exiftool + 2.3.15-SNAPSHOT + jar + exiftool + Enhanced Java Integration for Phil Harvey's ExifTool. + ${git.url} + + Github + https://github.com/raisercostin/${project.artifactId}/issues + + + + Riyad Kalla + software@thebuzzmedia.com + + + Costin Grigore + raisercostin@gmail.com + raisercostin + http://raisercostin.org + + + + scm:git:${git.url}.git + scm:git:${git.url}.git + ${git.url} + ${project.artifactId}-${project.version} + + + + bintray + https://api.bintray.com/maven/${bintray.user}/${bintray.repo}/${bintray.package}/;publish=1 + + + + + raisercostin + ${project.artifactId} + github.com + https://${git.host}/${git.userOrGroup}/${git.repo} + raisercostin + raisercostin + maven + ${project.artifactId} + yyyyMMdd'T'HHmmss + https://api.bintray.com/maven/${bintray.user}/${bintray.repo}/${bintray.package} + ${dist.url}/releases/ + ${dist.url}/snapshots/ + ${dist.url}/sites/${project.groupId}-${project.artifactId}-${project.version} + false + false + + com.revomatico + 1.2.0.Final + 1.18.4 + 5.4.2 + 1.4.2 + 1.7.25 + 1.2.3 + 3.2.5.RELEASE + 5.1.4.RELEASE + + jdt_apt + 2.1.1.RELEASE + UTF-8 + UTF-8 + 1.7 + ${java.version} + 1.7 + ${java.version} + 1.2.71 + true + enable + 1.3 + 1.3 + ${java.version} + + + + The Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + commons-io + commons-io + 2.6 + + + org.slf4j + slf4j-api + ${slf4j.version} + compile + + + com.google.guava + guava + 27.1-jre + + + joda-time + joda-time + 2.10.2 + + + junit + junit + 4.11 + test + + + + ch.qos.logback + logback-core + ${logback.version} + test + + + ch.qos.logback + logback-classic + ${logback.version} + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + + ${source.java.version} + ${target.java.version} + true + true + + + + org.apache.maven.plugins + maven-release-plugin + 2.5.3 + + + + + + release + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.7 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + + + + + + jcenter-bintray + https://jcenter.bintray.com/ + + true + + + false + + + + raisercostin-bintray + https://dl.bintray.com/raisercostin/maven + + true + + + false + + + + diff --git a/src/main/java/com/thebuzzmedia/exiftool/CleanupTimerTask.java b/src/main/java/com/thebuzzmedia/exiftool/CleanupTimerTask.java new file mode 100644 index 00000000..951d73ae --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/CleanupTimerTask.java @@ -0,0 +1,32 @@ +package com.thebuzzmedia.exiftool; + +import java.util.Timer; +import java.util.TimerTask; + + +/** + * Class used to represent the {@link TimerTask} used by the internal auto + * cleanup {@link Timer} to call {@link ExifToolNew3#close()} after a specified + * interval of inactivity. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +class CleanupTimerTask extends TimerTask { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(CleanupTimerTask.class); + private ExifToolNew2 owner; + + public CleanupTimerTask(ExifToolNew2 owner) throws IllegalArgumentException { + if (owner == null) + throw new IllegalArgumentException( + "owner cannot be null and must refer to the ExifToolNew3 instance creating this task."); + + this.owner = owner; + } + + @Override + public void run() { + LOG.info("\tAuto cleanup task running..."); + owner.close(); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/CustomTag.java b/src/main/java/com/thebuzzmedia/exiftool/CustomTag.java new file mode 100644 index 00000000..d927dd6f --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/CustomTag.java @@ -0,0 +1,45 @@ +package com.thebuzzmedia.exiftool; + +/** + * A Custom Tag that the user defines. Used to cover tags not in the enum. + */ +public class CustomTag implements MetadataTag { + private final String name; + private final Class type; + private final boolean mapped; + + public CustomTag(String name, Class type) { + this(name, type, !name.trim().endsWith(":all")); + } + + public CustomTag(String name, Class type, boolean mapped) { + this.name = name.trim(); + this.type = type; + this.mapped = mapped; + } + + @Override + public String getKey() { + return name; + } + + @Override + public Class getType() { + return type; + } + + @Override + public boolean isMapped() { + return mapped; + } + + @Override + public String toString() { + return getKey(); + } + + @Override + public String toExif(T value) { + return Tag.toExif(this, value); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/ExifError.java b/src/main/java/com/thebuzzmedia/exiftool/ExifError.java new file mode 100644 index 00000000..8f59325a --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/ExifError.java @@ -0,0 +1,20 @@ +package com.thebuzzmedia.exiftool; + + +/** + * Represents an error from the ExifToolNew3 + * + * @author msgile + * @author $LastChangedBy$ + * @version $Revision$ $LastChangedDate$ + * @since 7/25/14 + */ +public class ExifError extends RuntimeException { + public ExifError(String message) { + super(message); + } + + public ExifError(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/thebuzzmedia/exiftool/ExifProcess.java b/src/main/java/com/thebuzzmedia/exiftool/ExifProcess.java new file mode 100644 index 00000000..51679060 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/ExifProcess.java @@ -0,0 +1,329 @@ +package com.thebuzzmedia.exiftool; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.InvocationTargetException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; + +import com.google.common.base.Joiner; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.Collections2; +import com.google.common.collect.Lists; + +// ================================================================================ +/** + * Represents an external exif process. Works for both single use and keep alive modes. This is the actual process, with + * streams for reading and writing data. + */ +public final class ExifProcess { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(ExifProcess.class); + + private static class Pair { + final P1 _1; + final P2 _2; + + public Pair(P1 _1, P2 _2) { + this._1 = _1; + this._2 = _2; + } + + @Override + public String toString() { + return "Pair(" + _1 + "," + _2 + ")"; + } + } + + private static final Map> all = Collections + .synchronizedMap(new TreeMap>()); + static { + LOG.debug("addShutdownHook"); + Runtime.getRuntime().addShutdownHook(new Thread() { + public void run() { + if (!all.isEmpty()) { + LOG.debug("Close all not closed processes:" + all.keySet()); + for (Entry> item : new HashSet>>(all.entrySet())) { + LOG.debug("Close not closed process " + item, new RuntimeException()); + item.getValue()._2.close(); + } + } + } + }); + } + + public static VersionNumber readVersion(String exifCmd) { + ExifProcess process = new ExifProcess(false, Arrays.asList(exifCmd, "-ver"), Charset.defaultCharset()); + try { + return new VersionNumber(process.readLine()); + } catch (IOException ex) { + throw new RuntimeException(String.format("Unable to check version number of ExifToolNew3: %s", exifCmd)); + } finally { + process.close(); + } + } + + private static ExifProcess _execute(boolean keepAlive, List args, Charset charset) { + return new ExifProcess(keepAlive, args, charset); + } + + public static List executeToResults(String exifCmd, List args, Charset charset) throws IOException { + List newArgs = new ArrayList(args.size() + 1); + newArgs.add(exifCmd); + newArgs.addAll(args); + ExifProcess process = _execute(false, newArgs, charset); + try { + return process.readResponse(args); + } catch (Throwable e) { + throw new RuntimeException(String.format("When executing %s we got %s", toCmd(newArgs), e.getMessage()), e); + } finally { + process.close(); + } + } + + private static String toCmd(List args) { + StringBuilder sb = new StringBuilder(); + for (String arg : args) { + sb.append(arg).append(" "); + } + return sb.toString(); + } + + // + // public static String executeToString(String exifCmd, List args, Charset charset) throws IOException { + // return ExifProxy.$.toResponse(executeToResults(exifCmd,args,charset)); + // } + // + public static ExifProcess startup(String exifCmd, Charset charset) { + List args = Arrays.asList(exifCmd, "-stay_open", "True", "-@", "-"); + return _execute(true, args, charset); + } + + private final ReentrantLock closeLock = new ReentrantLock(false); + private final boolean keepAlive; + private final Process process; + private final BufferedReader reader; + private final OutputStreamWriter writer; + private final LineReaderThread errReader; + private volatile boolean closed = false; + + public ExifProcess(boolean keepAlive, List args, Charset charset) { + this.keepAlive = keepAlive; + LOG.debug(String.format("Attempting to start ExifToolNew3 process using args: %s", args)); + try { + LOG.info("start background process: " + Joiner.on(" ").join(args)); + this.process = new ProcessBuilder(args).start(); + all.put(process.toString(), new Pair(toString(new RuntimeException("start of " + process)), this)); + this.reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + this.writer = new OutputStreamWriter(process.getOutputStream(), charset); + this.errReader = new LineReaderThread("exif-process-err-reader", new BufferedReader(new InputStreamReader(process.getErrorStream()))); + this.errReader.setDaemon(true); + errReader.start(); + LOG.debug("\tSuccessful " + process + " started."); + } catch (Exception e) { + String message = "Unable to start external ExifToolNew3 process using the execution arguments: " + args + + ". Ensure ExifToolNew3 is installed correctly and runs using the command path '" + args.get(0) + + "' as specified by the 'exiftool.path' system property."; + + LOG.debug(message); + throw new RuntimeException(message, e); + } + } + + private String toString(Throwable throwable) { + StringWriter sw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(sw)); + return sw.getBuffer().toString(); + } + + public synchronized List sendToRunning(List args) throws IOException { + return sendArgs(args); + } + + public synchronized List sendArgs(List args) throws IOException { + if (!keepAlive) { + throw new IOException("Not KeepAlive Process"); + } + StringBuilder builder = new StringBuilder(); + for (String arg : args) { + builder.append(arg).append("\n"); + } + builder.append("-execute\n"); + LOG.info("exiftool " + Joiner.on(" ").join(args)); + writeFlush(builder.toString()); + return readResponse(args); + } + + public synchronized void writeFlush(String message) throws IOException { + if (closed) + throw new IOException(ExifToolNew3.STREAM_CLOSED_MESSAGE); + writer.write(message); + writer.flush(); + } + + public synchronized String readLine() throws IOException { + if (closed) + throw new IOException(ExifToolNew3.STREAM_CLOSED_MESSAGE); + return reader.readLine(); + } + + public synchronized List readResponse(List args) throws IOException { + if (closed) + throw new IOException(ExifToolNew3.STREAM_CLOSED_MESSAGE); + LOG.debug("Reading response back from ExifToolNew3..."); + //String line; + List all = new ArrayList(); +// if(!keepAlive) { +// boolean result = process.waitFor(10, TimeUnit.SECONDS); +// } + while(true){ + String line = reader.readLine(); + if (closed) { + LOG.info("stream closed message"); + throw new IOException(ExifToolNew3.STREAM_CLOSED_MESSAGE); + } + LOG.debug("stream line read [" + line + "]"); + if(!keepAlive && line==null) { + Preconditions.checkState(!process.isAlive()); + //if no keep alive then the null should be returned + break; + } + /* + * When using a persistent ExifToolNew3 process, it terminates its output to us with a "{ready}" clause on a new + * line, we need to look for it and break from this loop when we see it otherwise this process will hang + * indefinitely blocking on the input stream with no data to read. + */ + if (keepAlive && line.equals("{ready}")) { + break; + } + all.add(line); + } + //Preconditions.checkNotNull(line, "Should wait to get {ready}"); + String error = readError(); + if (error != null) { + // + // } + // if (all.isEmpty()) { + // //since no result came it is a chance we have an error + // String error = null; + // try { + // while ((error = readError()) == null) { + // Thread.currentThread().sleep(100); + // } + // } catch (InterruptedException e) { + // throw new RuntimeException("Didn't get anything back from exiftool with args [" + args + "].", e); + // } + String message = error + ". " + all.size() + " lines where read [" + all + "] for exiftool with args [" + args + "]."; + // if(result.contains("No matching files")){ + throw new ExifError(message); + // }else{ + // LOG.info(message); + // } + } + return all; + } + + private String readError() throws ExifError { + if (errReader.hasLines()) { + StringBuffer sb = new StringBuffer(); + for (String error : errReader.takeLines()) { + if (error.toLowerCase().startsWith("error")) { + throw new ExifError(error); + } + sb.append(error); + } + String result = sb.toString(); + return result; + } else { + return null; + } + } + + public boolean isClosed() { + return closed; + } + + public void close() { + if (!closed) { + closeLock.lock(); + try { + if (!closed) { + closed = true; + try { + LOG.debug("Closing Read stream..."); + reader.close(); + LOG.debug("\tSuccessful"); + } catch (Exception e) { + // no-op, just try to close it. + LOG.debug("", e); + } + + if(keepAlive) + try { + LOG.debug("Attempting to close ExifToolNew3 daemon process, issuing '-stay_open\\nFalse\\n' command..."); + writer.write("-stay_open\nFalse\n"); + writer.flush(); + } catch (IOException e) { + // log.error(ex,ex); + LOG.debug("", e); + } + + try { + LOG.debug("Closing Write stream..."); + writer.close(); + LOG.debug("\tSuccessful"); + } catch (Exception e) { + // no-op, just try to close it. + LOG.debug("", e); + } + + try { + LOG.debug("Closing Error stream..."); + errReader.close(); + LOG.debug("\tSuccessful"); + } catch (Exception e) { + // no-op, just try to close it. + LOG.debug("", e); + } + LOG.debug("Read/Write streams successfully closed."); + + try { + LOG.debug("\tDestroy process " + process + "..."); + process.destroy(); + all.remove(process.toString()); + LOG.debug("\tDestroy process " + process + " done => " + all.keySet()); + } catch (Exception e) { + // + LOG.debug("", e); + } + // process = null; + + } + } finally { + closeLock.unlock(); + } + } + } + + @Override + protected void finalize() throws Throwable { + LOG.debug("\tFinalize process " + process + "."); + close(); + super.finalize(); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/ExifProxy.java b/src/main/java/com/thebuzzmedia/exiftool/ExifProxy.java new file mode 100644 index 00000000..dcc5e779 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/ExifProxy.java @@ -0,0 +1,25 @@ +package com.thebuzzmedia.exiftool; + +import java.util.*; +import java.util.regex.Pattern; + +import com.google.common.base.Joiner; + +/** + * A Proxy to an Exif Process, will restart if backing exif process died, or run new one on every call. + * + * @author Matt Gile, msgile + */ +public interface ExifProxy { + public void startup(); + + public List execute(long runTimeoutMills, List args); + + public boolean isRunning(); + + public void shutdown(); + + public static class $ { + + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/ExifTool.java b/src/main/java/com/thebuzzmedia/exiftool/ExifTool.java index 9785783d..f6c95e8e 100644 --- a/src/main/java/com/thebuzzmedia/exiftool/ExifTool.java +++ b/src/main/java/com/thebuzzmedia/exiftool/ExifTool.java @@ -18,8 +18,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.thebuzzmedia.exiftool.adapters.ExifToolService; + import java.io.BufferedReader; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; @@ -202,7 +205,7 @@ * @author Riyad Kalla (software@thebuzzmedia.com) * @since 1.1 */ -public class ExifTool { +public class ExifTool implements RawExifTool { /** * If ExifTool is on your system path and running the command "exiftool" @@ -271,15 +274,10 @@ public class ExifTool { */ private static final String CLEANUP_THREAD_NAME = "ExifTool Cleanup Thread"; - /** - * Compiled {@link Pattern} of ": " used to split compact output from - * ExifTool evenly into name/value pairs. - */ - private static final Pattern TAG_VALUE_PATTERN = Pattern.compile("\\s*:\\s*"); private static final String STREAM_CLOSED_MESSAGE = "Stream closed"; private static final String EXIF_DATE_FORMAT = "yyyy:MM:dd HH:mm:ss"; - private static Logger log = LoggerFactory.getLogger(ExifTool.class); + private static final Logger log = LoggerFactory.getLogger(ExifTool.class); private final Map featureSupportedMap = new HashMap(); private final Set featureEnabledSet = EnumSet.noneOf(Feature.class); @@ -333,9 +331,7 @@ public ExifTool(String exifCmd, long processCleanupDelay, Feature ... features) baseArgs.addAll(Arrays.asList("-use","MWG")); } if (featureEnabledSet.contains(Feature.STAY_OPEN) ) { - KeepAliveExifProxy proxy = new KeepAliveExifProxy(exifCmd,baseArgs); - proxy.setInactiveTimeout(processCleanupDelay); - exifProxy = proxy; + exifProxy = new KeepAliveExifProxy(exifCmd,baseArgs,processCleanupDelay); } else { exifProxy = new SingleUseExifProxy(exifCmd,baseArgs); } @@ -430,14 +426,16 @@ public boolean isFeatureEnabled(Feature feature) throws IllegalArgumentException * streams used to communicate with it when {@link Feature#STAY_OPEN} is * enabled. This method has no effect if the stay open feature is not enabled. */ - public void startup(){ - exifProxy.startup(); - } +// @Override +//public void startup(){ +// exifProxy.startup(); +// } /** * This is same as {@link #close()}, added for consistency with {@link #startup()} */ - public void shutdown(){ + @Override +public void shutdown(){ close(); } @@ -458,7 +456,8 @@ public void close() { exifProxy.shutdown(); } - public boolean isStayOpen() { + @Override +public boolean isStayOpen() { return featureEnabledSet.contains(Feature.STAY_OPEN); } @@ -539,6 +538,7 @@ public Map getImageMeta(File file, Format format, boolean supress return data; } + @Override public void addImageMetadata(File image, Map values) throws IOException { writeMetadata(defWriteOptions.withDeleteBackupFile(false),image,values); } @@ -577,8 +577,9 @@ public Map readMetadata(ReadOptions options, File file, Object... } args.add(file.getAbsolutePath()); - Map resultMap = exifProxy.execute(options.runTimeoutMills,args); + Map resultMap = ExifToolService.toMap(execute(options,args)); + if(options.convertTypes){ Map metadata = new HashMap(resultMap.size()); for(Object tag: tags) { @@ -612,34 +613,35 @@ public Map readMetadata(ReadOptions options, File file, Object... } } return metadata; + }else{ + return (Map)resultMap; + } } public void writeMetadata(File image, Map values) throws IOException { writeMetadata(defWriteOptions, image, values); } + /** * Takes a map of tags (either (@link Tag) or Strings for keys) and replaces/appends them to the metadata. */ - public void writeMetadata(WriteOptions options, File image, Map values) throws IOException { - if (image == null){ - throw new IllegalArgumentException("image cannot be null and must be a valid stream of image data."); - } - if (values == null || values.isEmpty()){ - throw new IllegalArgumentException("values cannot be null and must contain 1 or more tag to value mappings"); - } - - if (!image.canWrite()){ - throw new SecurityException("Unable to write the given image [" + image.getAbsolutePath() - + "], ensure that the image exists at the given path and that the executing Java process has permissions to write to it."); - } + public void writeMetadata(WriteOptions options, File file, Map values) throws IOException { + if ( file == null ) throw new NullPointerException("File is null"); + if ( ! file.exists() ) throw new FileNotFoundException(String.format("File \"%s\" does not exits",file.getAbsolutePath())); + if ( ! file.canWrite() ) throw new SecurityException(String.format("File \"%s\" cannot be written to",file.getAbsolutePath())); - log.info("Adding Tags {} to {}", values, image.getAbsolutePath()); + log.info("Adding Tags {} to {}", values, file.getAbsolutePath()); List args = new ArrayList(values.size()+3); + + if ( options.ignoreMinorErrors ) { + args.add("-ignoreMinorErrors"); + } + for(Map.Entry entry : values.entrySet()) { args.addAll(serializeToArgs(entry.getKey(),entry.getValue())); } - args.add(image.getAbsolutePath()); + args.add(file.getAbsolutePath()); //start process long startTime = System.currentTimeMillis(); @@ -647,15 +649,40 @@ public void writeMetadata(WriteOptions options, File image, Map v exifProxy.execute(options.runTimeoutMills, args); } finally { if ( options.deleteBackupFile ) { - File origBackup = new File(image.getAbsolutePath()+"_original"); + File origBackup = new File(file.getAbsolutePath()+"_original"); if ( origBackup.exists() ) origBackup.delete(); } } // Print out how long the call to external ExifTool process took. - if (log.isDebugEnabled()){ - log.debug(String.format("Image Meta Processed in %d ms [added %d tags]", - (System.currentTimeMillis() - startTime), values.size())); + if (log.isDebugEnabled()) log.debug(String.format("Image Meta Processed in %d ms [added %d tags]", (System.currentTimeMillis() - startTime), values.size())); + + } + + @Override +public void rebuildMetadata(File file) throws IOException { + rebuildMetadata(getWriteOptions(),file); + } + + /** + * Rewrite all the the metadata tags in a JPEG image. This will not work for TIFF files. + * Use this when the image has some corrupt tags. + * + * @link http://www.sno.phy.queensu.ca/~phil/exiftool/faq.html#Q20 + */ + public void rebuildMetadata(WriteOptions options, File file) throws IOException { + if ( file == null ) throw new NullPointerException("File is null"); + if ( ! file.exists() ) throw new FileNotFoundException(String.format("File \"%s\" does not exits",file.getAbsolutePath())); + if ( ! file.canWrite() ) throw new SecurityException(String.format("File \"%s\" cannot be written to",file.getAbsolutePath())); + + List args = Arrays.asList("-all=", "-tagsfromfile", "@", "-all:all", "-unsafe",file.getAbsolutePath()); + try { + exifProxy.execute(options.runTimeoutMills, args); + } finally { + if ( options.deleteBackupFile ) { + File origBackup = new File(file.getAbsolutePath()+"_original"); + if ( origBackup.exists() ) origBackup.delete(); + } } } //================================================================================ @@ -791,6 +818,12 @@ static MetadataTag toTag(String name) { * This is the actual process, with streams for reading and writing data. */ public static final class ExifProcess { + /** + * Compiled {@link Pattern} of ": " used to split compact output from + * ExifTool evenly into name/value pairs. + */ + private static final Pattern TAG_VALUE_PATTERN = Pattern.compile("\\s*:\\s*"); + public static VersionNumber readVersion(String exifCmd) { ExifProcess process = new ExifProcess(false, Arrays.asList(exifCmd, "-ver")); try { @@ -805,8 +838,9 @@ public static VersionNumber readVersion(String exifCmd) { private final ReentrantLock closeLock = new ReentrantLock(false); private final boolean keepAlive; private final Process process; - private final BufferedReader reader; - private final OutputStreamWriter writer; + private final BufferedReader out; + private final OutputStreamWriter in; + private final LineReaderThread errReader; private volatile boolean closed = false; public ExifProcess(boolean keepAlive, List args) { @@ -814,8 +848,10 @@ public ExifProcess(boolean keepAlive, List args) { log.debug(String.format("Attempting to start ExifTool process using args: %s", args)); try { this.process = new ProcessBuilder(args).start(); - this.reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - this.writer = new OutputStreamWriter(process.getOutputStream()); + this.out = new BufferedReader(new InputStreamReader(process.getInputStream())); + this.in = new OutputStreamWriter(process.getOutputStream()); + this.errReader = new LineReaderThread("exif-process-err-reader",new BufferedReader(new InputStreamReader(process.getErrorStream()))); + errReader.start(); log.debug("\tSuccessful"); } catch (Exception e) { String message = "Unable to start external ExifTool process using the execution arguments: " @@ -844,13 +880,13 @@ public synchronized Map sendToRunning(List args) throws public synchronized void writeFlush(String message) throws IOException { if (closed) throw new IOException(STREAM_CLOSED_MESSAGE); - writer.write(message); - writer.flush(); + in.write(message); + in.flush(); } public synchronized String readLine() throws IOException { if (closed) throw new IOException(STREAM_CLOSED_MESSAGE); - return reader.readLine(); + return out.readLine(); } public synchronized Map readResponse() throws IOException { @@ -858,27 +894,32 @@ public synchronized Map readResponse() throws IOException { log.debug("Reading response back from ExifTool..."); Map resultMap = new HashMap(500); String line; - - while ((line = reader.readLine()) != null) { + while ((line = out.readLine()) != null) { if (closed) throw new IOException(STREAM_CLOSED_MESSAGE); String[] pair = TAG_VALUE_PATTERN.split(line, 2); - if (pair.length == 2) { resultMap.put(pair[0], pair[1]); log.debug(String.format("\tRead Tag [name=%s, value=%s]", pair[0], pair[1])); } - /* - * When using a persistent ExifTool process, it terminates its - * output to us with a "{ready}" clause on a new line, we need to - * look for it and break from this loop when we see it otherwise - * this process will hang indefinitely blocking on the input stream - * with no data to read. - */ + /* + * When using a persistent ExifTool process, it terminates its + * output to us with a "{ready}" clause on a new line, we need to + * look for it and break from this loop when we see it otherwise + * this process will hang indefinitely blocking on the input stream + * with no data to read. + */ if (keepAlive && line.equals("{ready}")) { break; } } + if ( errReader.hasLines() ) { + for(String error : errReader.takeLines()) { + if ( error.toLowerCase().startsWith("error") ) { + throw new ExifError(error); + } + } + } return resultMap; } @@ -894,7 +935,7 @@ public void close() { closed = true; try { log.debug("Closing Read stream..."); - reader.close(); + out.close(); log.debug("\tSuccessful"); } catch (Exception e) { // no-op, just try to close it. @@ -902,15 +943,23 @@ public void close() { try { log.debug("Attempting to close ExifTool daemon process, issuing '-stay_open\\nFalse\\n' command..."); - writer.write("-stay_open\nFalse\n"); - writer.flush(); + in.write("-stay_open\nFalse\n"); + in.flush(); } catch (IOException ex) { //log.error(ex,ex); } try { log.debug("Closing Write stream..."); - writer.close(); + in.close(); + log.debug("\tSuccessful"); + } catch (Exception e) { + // no-op, just try to close it. + } + + try { + log.debug("Closing Error stream..."); + errReader.close(); log.debug("\tSuccessful"); } catch (Exception e) { // no-op, just try to close it. @@ -937,7 +986,7 @@ public void close() { * A Proxy to an Exif Process, will restart if backing exif process died, or run new one on every call. * @author Matt Gile, msgile */ - public interface ExifProxy { + public interface ExifProxyOld { public void startup(); public Map execute(long runTimeoutMills, List args) throws IOException; public boolean isRunning(); @@ -947,7 +996,7 @@ public interface ExifProxy { /** * Manages an external exif process in keep alive mode. */ - public static class KeepAliveExifProxy implements ExifProxy { + public static class KeepAliveExifProxyOld implements ExifProxyOld { private final List startupArgs; private final AtomicBoolean shuttingDown = new AtomicBoolean(false); private final Timer cleanupTimer = new Timer(CLEANUP_THREAD_NAME, true); @@ -955,7 +1004,7 @@ public static class KeepAliveExifProxy implements ExifProxy { private volatile long lastRunStart = 0; private volatile ExifProcess process; - public KeepAliveExifProxy(String exifCmd, List baseArgs) { + public KeepAliveExifProxyOld(String exifCmd, List baseArgs) { inactivityTimeout = Long.getLong(ENV_EXIF_TOOL_PROCESSCLEANUPDELAY, DEFAULT_PROCESS_CLEANUP_DELAY); startupArgs = new ArrayList(baseArgs.size()+5); startupArgs.add(exifCmd); @@ -1050,11 +1099,11 @@ public void shutdown() { } } - public static class SingleUseExifProxy implements ExifProxy { + public static class SingleUseExifProxyOld implements ExifProxyOld { private final Timer cleanupTimer = new Timer(CLEANUP_THREAD_NAME, true); private final List baseArgs; - public SingleUseExifProxy(String exifCmd, List defaultArgs) { + public SingleUseExifProxyOld(String exifCmd, List defaultArgs) { this.baseArgs = new ArrayList(defaultArgs.size()+1); this.baseArgs.add(exifCmd); this.baseArgs.addAll(defaultArgs); @@ -1101,18 +1150,18 @@ public void shutdown() { /** * All the read options, is immutable, copy on change, fluent style "with" setters. */ - public static class ReadOptions { + public static class ReadOptionsOld { private final long runTimeoutMills; private final boolean convertTypes; private final boolean numericOutput; private final boolean showDuplicates; private final boolean showEmptyTags; - public ReadOptions() { + public ReadOptionsOld() { this(0,false,false,false,false); } - private ReadOptions(long runTimeoutMills, boolean convertTypes, boolean numericOutput, boolean showDuplicates, boolean showEmptyTags) { + private ReadOptionsOld(long runTimeoutMills, boolean convertTypes, boolean numericOutput, boolean showDuplicates, boolean showEmptyTags) { this.runTimeoutMills = runTimeoutMills; this.convertTypes = convertTypes; this.numericOutput = numericOutput; @@ -1128,16 +1177,16 @@ public String toString() { /** * Sets the maximum time a process can run */ - public ReadOptions withRunTimeoutMills(long mills) { - return new ReadOptions(mills,convertTypes,numericOutput, showDuplicates,showEmptyTags); + public ReadOptionsOld withRunTimeoutMills(long mills) { + return new ReadOptionsOld(mills,convertTypes,numericOutput, showDuplicates,showEmptyTags); } /** * By default all values will be returned as the strings printed by the exiftool. * If this is enabled then {@link MetadataTag#getType()} is used to cast the string into a java type. */ - public ReadOptions withConvertTypes(boolean enabled) { - return new ReadOptions(runTimeoutMills,enabled,numericOutput, showDuplicates,showEmptyTags); + public ReadOptionsOld withConvertTypes(boolean enabled) { + return new ReadOptionsOld(runTimeoutMills,enabled,numericOutput, showDuplicates,showEmptyTags); } /** @@ -1171,19 +1220,19 @@ public ReadOptions withConvertTypes(boolean enabled) { * Attempted from work done by * @author Riyad Kalla (software@thebuzzmedia.com) */ - public ReadOptions withNumericOutput(boolean enabled) { - return new ReadOptions(runTimeoutMills,convertTypes,enabled, showDuplicates,showEmptyTags); + public ReadOptionsOld withNumericOutput(boolean enabled) { + return new ReadOptionsOld(runTimeoutMills,convertTypes,enabled, showDuplicates,showEmptyTags); } /** * If enabled will show tags which are duplicated between different tag regions, relates to the "-a" option in ExifTool. */ - public ReadOptions withShowDuplicates(boolean enabled) { - return new ReadOptions(runTimeoutMills,convertTypes,numericOutput,enabled,showEmptyTags); + public ReadOptionsOld withShowDuplicates(boolean enabled) { + return new ReadOptionsOld(runTimeoutMills,convertTypes,numericOutput,enabled,showEmptyTags); } - public ReadOptions withShowEmptyTags(boolean enabled) { - return new ReadOptions(runTimeoutMills,convertTypes,numericOutput,showDuplicates,enabled); + public ReadOptionsOld withShowEmptyTags(boolean enabled) { + return new ReadOptionsOld(runTimeoutMills,convertTypes,numericOutput,showDuplicates,enabled); } } @@ -1191,32 +1240,38 @@ public ReadOptions withShowEmptyTags(boolean enabled) { /** * All the write options, is immutable, copy on change, fluent style "with" setters. */ - public static class WriteOptions { + public static class WriteOptionsOld extends WriteOptions{ private final long runTimeoutMills; private final boolean deleteBackupFile; + private final boolean ignoreMinorErrors; - public WriteOptions() { - this(0,false); + public WriteOptionsOld() { + this(0,false, false); } - private WriteOptions(long runTimeoutMills, boolean deleteBackupFile) { + private WriteOptionsOld(long runTimeoutMills, boolean deleteBackupFile, boolean ignoreMinorErrors) { this.runTimeoutMills = runTimeoutMills; this.deleteBackupFile = deleteBackupFile; + this.ignoreMinorErrors = ignoreMinorErrors; } public String toString() { - return String.format("%s(runTimeOut:%,d deleteBackupFile:%s)",getClass().getSimpleName(),runTimeoutMills,deleteBackupFile); + return String.format("%s(runTimeOut:%,d deleteBackupFile:%s ignoreMinorErrors:%s)",getClass().getSimpleName(),runTimeoutMills,deleteBackupFile,ignoreMinorErrors); } - public WriteOptions withRunTimeoutMills(long mills) { - return new WriteOptions(mills,deleteBackupFile); + public WriteOptionsOld withRunTimeoutMills(long mills) { + return new WriteOptionsOld(mills,deleteBackupFile, ignoreMinorErrors); } /** * ExifTool automatically makes a backup copy a file before writing metadata tags in the form * "file.ext_original", by default this tool will delete that original file after the writing is done. */ - public WriteOptions withDeleteBackupFile(boolean enabled) { - return new WriteOptions(runTimeoutMills,enabled); + public WriteOptionsOld withDeleteBackupFile(boolean enabled) { + return new WriteOptionsOld(runTimeoutMills,enabled, ignoreMinorErrors); + } + + public WriteOptionsOld withIgnoreMinorErrors(boolean enabled) { + return new WriteOptionsOld(runTimeoutMills,deleteBackupFile, enabled); } } @@ -1233,7 +1288,7 @@ public WriteOptions withDeleteBackupFile(boolean enabled) { * @author Riyad Kalla (software@thebuzzmedia.com) * @since 1.1 */ - public enum Feature { + public enum FeatureOld { /** * Enum used to specify that you wish to launch the underlying ExifTool * process with -stay_open True support turned on that this @@ -1257,7 +1312,7 @@ public enum Feature { private VersionNumber requireVersion; - private Feature(int... numbers) { + private FeatureOld(int... numbers) { this.requireVersion = new VersionNumber(numbers); } /** @@ -1281,10 +1336,10 @@ boolean isSupported(VersionNumber exifVersionNumber) { * Version Number used to determine if one version is after another. * @author Matt Gile, msgile */ - static class VersionNumber { + static class VersionNumberOld { private final int[] numbers; - public VersionNumber(String str) { + public VersionNumberOld(String str) { String[] versionParts = str.trim().split("\\."); this.numbers = new int[versionParts.length]; for(int i=0; i other.numbers[i] ) { @@ -1377,58 +1432,65 @@ public interface MetadataTag { public enum Tag implements MetadataTag { //single entry tags APERTURE("ApertureValue", Double.class), - AUTHOR("XPAuthor", String.class), + ARTIST("Artist", String.class), + AUTHOR( "XPAuthor", String.class), + CAPTION_ABSTRACT("Caption-Abstract", String.class), COLOR_SPACE("ColorSpace", Integer.class), COMMENT("XPComment", String.class), CONTRAST("Contrast", Integer.class), - CREATE_DATE("CreateDate", Date.class), - CREATION_DATE("CreationDate", Date.class), - DATE_CREATED("DateCreated", Date.class), - DATE_TIME_ORIGINAL("DateTimeOriginal", Date.class), + COPYRIGHT("Copyright", String.class), + COPYRIGHT_NOTICE("CopyrightNotice", String.class), + CREATION_DATE("CreationDate", String.class), + CREATOR("Creator", String.class), + DATE_TIME_ORIGINAL("DateTimeOriginal", String.class), DIGITAL_ZOOM_RATIO("DigitalZoomRatio", Double.class), - EXIF_VERSION("ExifVersion", String.class), - EXPOSURE_COMPENSATION("ExposureCompensation", Double.class), - EXPOSURE_PROGRAM("ExposureProgram", Integer.class), - EXPOSURE_TIME("ExposureTime", Double.class), - FLASH("Flash", Integer.class), + EXIF_VERSION("ExifVersion",String.class), + EXPOSURE_COMPENSATION( "ExposureCompensation", Double.class), + EXPOSURE_PROGRAM( "ExposureProgram", Integer.class), + EXPOSURE_TIME( "ExposureTime", Double.class), + FLASH( "Flash", Integer.class), FOCAL_LENGTH("FocalLength", Double.class), - FOCAL_LENGTH_35MM("FocalLengthIn35mmFormat", Integer.class), + FOCAL_LENGTH_35MM( "FocalLengthIn35mmFormat", Integer.class), FNUMBER("FNumber", String.class), GPS_ALTITUDE("GPSAltitude", Double.class), - GPS_ALTITUDE_REF("GPSAltitudeRef", Integer.class), - GPS_BEARING("GPSDestBearing", Double.class), - GPS_BEARING_REF("GPSDestBearingRef", String.class), + GPS_ALTITUDE_REF( "GPSAltitudeRef", Integer.class), + GPS_BEARING( "GPSDestBearing", Double.class), + GPS_BEARING_REF( "GPSDestBearingRef", String.class), GPS_DATESTAMP("GPSDateStamp", String.class), - GPS_LATITUDE("GPSLatitude", Double.class), - GPS_LATITUDE_REF("GPSLatitudeRef", String.class), + GPS_LATITUDE( "GPSLatitude", Double.class), + GPS_LATITUDE_REF( "GPSLatitudeRef", String.class), GPS_LONGITUDE("GPSLongitude", Double.class), GPS_LONGITUDE_REF("GPSLongitudeRef", String.class), - GPS_PROCESS_METHOD("GPSProcessingMethod", String.class), + GPS_PROCESS_METHOD( "GPSProcessingMethod", String.class), GPS_SPEED("GPSSpeed", Double.class), GPS_SPEED_REF("GPSSpeedRef", String.class), - GPS_TIMESTAMP("GPSTimeStamp", String.class), - IMAGE_HEIGHT("ImageHeight", Integer.class), + GPS_TIMESTAMP( "GPSTimeStamp", String.class), + IMAGE_HEIGHT( "ImageHeight", Integer.class), IMAGE_WIDTH("ImageWidth", Integer.class), + IPTC_KEYWORDS("Keywords", String.class), ISO("ISO", Integer.class), - KEYWORDS("XPKeywords", String.class), + KEYWORDS( "XPKeywords", String.class), + LENS_ID("LensID",String.class), LENS_MAKE("LensMake", String.class), - LENS_MODEL("LensModel", String.class), + LENS_MODEL( "LensModel", String.class), MAKE("Make", String.class), METERING_MODE("MeteringMode", Integer.class), MODEL("Model", String.class), + OBJECT_NAME("ObjectName", String.class), ORIENTATION("Orientation", Integer.class), OWNER_NAME("OwnerName", String.class), - RATING("Rating", Integer.class), + RATING( "Rating", Integer.class), RATING_PERCENT("RatingPercent", Integer.class), - ROTATION("Rotation", Integer.class), + ROTATION("Rotation",Integer.class), SATURATION("Saturation", Integer.class), - SENSING_METHOD("SensingMethod", Integer.class), - SHARPNESS("Sharpness", Integer.class), + SENSING_METHOD( "SensingMethod", Integer.class), + SHARPNESS( "Sharpness", Integer.class), SHUTTER_SPEED("ShutterSpeedValue", Double.class), SOFTWARE("Software", String.class), SUBJECT("XPSubject", String.class), + SUB_SEC_TIME_ORIGINAL("SubSecTimeOriginal", Integer.class), TITLE("XPTitle", String.class), - WHITE_BALANCE("WhiteBalance", Integer.class), + WHITE_BALANCE( "WhiteBalance", Integer.class), X_RESOLUTION("XResolution", Double.class), Y_RESOLUTION("YResolution", Double.class), ; @@ -1660,4 +1722,17 @@ public UnsupportedFeatureException(Feature feature) { + " or higher of the native ExifTool program. The version of ExifTool referenced by the system property 'exiftool.path' is not high enough. You can either upgrade the install of ExifTool or avoid using this feature to workaround this exception."); } } + + @Override + public List execute(List args) { + return exifProxy.execute(defReadOptions.runTimeoutMills,args); + } + public List execute(ReadOptions options,List args) { + return exifProxy.execute(options.runTimeoutMills,args); + } + + @Override + public Map getImageMeta(File file, ReadOptions readOptions, String... tags) throws IOException { + return (Map)readMetadata(readOptions.withConvertTypes(false),file,tags); + } } \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew.java b/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew.java new file mode 100644 index 00000000..9a26062f --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew.java @@ -0,0 +1,638 @@ +/** + * Copyright 2011 The Buzz Media, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.thebuzzmedia.exiftool; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.lang.reflect.Array; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.TreeMap; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.thebuzzmedia.exiftool.adapters.ExifToolService; + +/** + * Provide a Java-like interface to Phil Harvey's excellent, Perl-based ExifToolNew3. + *

+ * Initial work done by "Riyad Kalla" software@thebuzzmedia.com. + *

+ * There are a number of other basic Java wrappers to ExifToolNew3 available online, but most of them only abstract out + * the actual Java-external-process execution logic and do no additional work to make integration with the external + * ExifToolNew3 any easier or intuitive from the perspective of the Java application written to make use of + * ExifToolNew3. + *

+ * This class was written in order to make integration with ExifToolNew3 inside of a Java application seamless and + * performant with the goal being that the developer can treat ExifToolNew3 as if it were written in Java, garnering all + * of the benefits with none of the added headache of managing an external native process from Java. + *

+ * Phil Harvey's ExifToolNew3 is written in Perl and runs on all major platforms (including Windows) so no portability + * issues are introduced into your application by utilizing this class. + *

Usage

+ * Assuming ExifToolNew3 is installed on the host system correctly and either in the system path or pointed to by + * {@link #ENV_EXIF_TOOL_PATH}, using this class to communicate with ExifToolNew3 is as simple as creating an instance ( + * ExifToolNew3 tool = new ExifToolNew3()) and then making calls to + * {@link #getImageMeta7(java.io.File,ReadOptions, Object...)} (optionally supplying tags or + * {@link #writeMetadata(WriteOptions,java.io.File, java.util.Map)} + *

+ * In this default mode methods will automatically start an external ExifToolNew3 process to handle the request. After + * ExifToolNew3 has parsed the tag values from the file, the external process exits and this class parses the result + * before returning it to the caller. + *

+ *

ExifToolNew3 -stay_open Support

+ * ExifToolNew3 8.36 added a new persistent-process feature that allows ExifToolNew3 to stay running in a daemon mode and + * continue accepting commands via a file or stdin. + *

+ * This new mode is controlled via the -stay_open True/False command line argument and in a busy system + * that is making thousands of calls to ExifToolNew3, can offer speed improvements of up to 60x (yes, + * really that much). + *

+ * This feature was added to ExifToolNew3 shortly after user Christian + * Etter discovered the overhead for starting up a new Perl interpreter each time ExifToolNew3 is loaded accounts + * for roughly 98.4% of + * the total runtime. + *

+ * Support for using ExifToolNew3 in daemon mode is enabled by passing {@link Feature#STAY_OPEN} to the constructor of + * the class when creating an instance of this class and then simply using the class as you normally would. This class + * will manage a single ExifToolNew3 process running in daemon mode in the background to service all future calls to the + * class. + *

+ * Because this feature requires ExifToolNew3 8.36 or later, this class will actually verify support for the feature in + * the version of ExifToolNew3 pointed at by {@link #ENV_EXIF_TOOL_PATH} before successfully instantiating the class and + * will notify you via an {@link UnsupportedFeatureException} if the native ExifToolNew3 doesn't support the requested + * feature. + *

+ * In the event of an {@link UnsupportedFeatureException}, the caller can either upgrade the native ExifToolNew3 upgrade + * to the version required or simply avoid using that feature to work around the exception. + *

Automatic Resource Cleanup

+ * When {@link Feature#STAY_OPEN} mode is used, there is the potential for leaking both host OS processes (native + * 'exiftool' processes) as well as the read/write streams used to communicate with it unless {@link #close()} is called + * to clean them up when done. Fortunately, this class provides an automatic cleanup mechanism that + * runs, by default, after 10mins of inactivity to clean up those stray resources. + *

+ * The inactivity period can be controlled by modifying the {@link #ENV_EXIF_TOOL_PROCESSCLEANUPDELAY} system variable. + * A value of 0 or less disabled the automatic cleanup process and requires you to cleanup ExifToolNew3 + * instances on your own by calling {@link #close()} manually. + *

+ * Any class activity by way of calls to getImageMeta will always reset the inactivity timer, so in a busy + * system the cleanup thread could potentially never run, leaving the original host ExifToolNew3 process running forever + * (which is fine). + *

+ * This design was chosen to help make using the class and not introducing memory leaks and bugs into your code easier + * as well as making very inactive instances of this class light weight while not in-use by cleaning up after + * themselves. + *

+ * The only overhead incurred when opening the process back up is a 250-500ms lag while launching the VM interpreter + * again on the first call (depending on host machine speed and load). + *

Reusing a "closed" ExifToolNew3 Instance

+ * If you or the cleanup thread have called {@link #close()} on an instance of this class, cleaning up the host process + * and read/write streams, the instance of this class can still be safely used. Any followup calls to + * getImageMeta will simply re-instantiate all the required resources necessary to service the call + * (honoring any {@link Feature}s set). + *

+ * This can be handy behavior to be aware of when writing scheduled processing jobs that may wake up every hour and + * process thousands of pictures then go back to sleep. In order for the process to execute as fast as possible, you + * would want to use ExifToolNew3 in daemon mode (pass {@link Feature#STAY_OPEN} to the constructor of this class) and + * when done, instead of {@link #close()}-ing the instance of this class and throwing it out, you can keep the reference + * around and re-use it again when the job executes again an hour later. + *

Performance

+ * Extra care is taken to ensure minimal object creation or unnecessary CPU overhead while communicating with the + * external process. + *

+ * {@link Pattern}s used to split the responses from the process are explicitly compiled and reused, string + * concatenation is minimized, Tag name lookup is done via a static final {@link Map} shared by all + * instances and so on. + *

+ * Additionally, extra care is taken to utilize the most optimal code paths when initiating and using the external + * process, for example, the {@link ProcessBuilder#command(List)} method is used to avoid the copying of array elements + * when {@link ProcessBuilder#command(String...)} is used and avoiding the (hidden) use of {@link StringTokenizer} when + * {@link Runtime#exec(String)} is called. + *

+ * All of this effort was done to ensure that imgscalr and its supporting classes continue to provide best-of-breed + * performance and memory utilization in long running/high performance environments (e.g. web applications). + *

Thread Safety

+ * Instances of this class are not Thread-safe. Both the instance of this class and external + * ExifToolNew3 process maintain state specific to the current operation. Use of instances of this class need to be + * synchronized using an external mechanism or in a highly threaded environment (e.g. web application), instances of + * this class can be used along with {@link ThreadLocal}s to ensure Thread-safe, highly parallel use. + *

Why ExifToolNew3?

+ * ExifToolNew3 is written in Perl and requires an external + * process call from Java to make use of. + *

+ * While this would normally preclude a piece of software from inclusion into the imgscalr library (more complex + * integration), there is no other image metadata piece of software available as robust, complete and well-tested as + * ExifToolNew3. In addition, ExifToolNew3 already runs on all major platforms (including Windows), so there was not a + * lack of portability introduced by providing an integration for it. + *

+ * Allowing it to be used from Java is a boon to any Java project that needs the ability to read/write image-metadata + * from almost any image or video file format. + *

Alternatives

+ * If integration with an external Perl process is something your app cannot do and you still need image + * metadata-extraction capability, Drew Noakes has written the 2nd most robust image metadata library I have come + * across: Metadata Extractor that you might want to look + * at. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +public class ExifToolNew implements RawExifTool { + + /** + * If ExifToolNew3 is on your system path and running the command "exiftool" successfully executes it, the default + * value unchanged will work fine on any platform. If the ExifToolNew3 executable is named something else or not in + * the system path, then this property will need to be set to point at it before using this class. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.path=/path/to/exiftool + * or by calling {@link System#setProperty(String, String)} before this class is loaded. + *

+ * On Windows be sure to double-escape the path to the tool, for example: + * -Dexiftool.path=C:\\Tools\\exiftool.exe + * + *

+ * Default value is "exiftool". + *

Relative Paths

+ * Relative path values (e.g. "bin/tools/exiftool") are executed with relation to the base directory the VM process + * was started in. Essentially the directory that new File(".").getAbsolutePath() points at during + * runtime. + */ + private static final String ENV_EXIF_TOOL_PATH = "exiftool.path"; + /** + * Interval (in milliseconds) of inactivity before the cleanup thread wakes up and cleans up the daemon ExifToolNew3 + * process and the read/write streams used to communicate with it when the {@link Feature#STAY_OPEN} feature is + * used. + *

+ * Ever time a call to getImageMeta is processed, the timer keeping track of cleanup is reset; more + * specifically, this class has to experience no activity for this duration of time before the cleanup process is + * fired up and cleans up the host OS process and the stream resources. + *

+ * Any subsequent calls to getImageMeta after a cleanup simply re-initializes the resources. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.processCleanupDelay=600000 + * or by calling {@link System#setProperty(String, String)} before this class is loaded. + *

+ * Setting this value to 0 disables the automatic cleanup thread completely and the caller will need to manually + * cleanup the external ExifToolNew3 process and read/write streams by calling {@link #close()}. + *

+ * Default value is zero, no inactivity timeout. + */ + static final String ENV_EXIF_TOOL_PROCESSCLEANUPDELAY = "exiftool.processCleanupDelay"; + static final long DEFAULT_PROCESS_CLEANUP_DELAY = 0; + + /** + * Name used to identify the (optional) cleanup {@link Thread}. + *

+ * This is only provided to make debugging and profiling easier for implementers making use of this class such that + * the resources this class creates and uses (i.e. Threads) are readily identifiable in a running VM. + *

+ * Default value is "ExifToolNew3 Cleanup Thread". + */ + private static final String CLEANUP_THREAD_NAME = "ExifToolNew3 Cleanup Thread"; + + private static final String STREAM_CLOSED_MESSAGE = "Stream closed"; + static final String EXIF_DATE_FORMAT = "yyyy:MM:dd HH:mm:ss"; + + private static final Logger log = LoggerFactory.getLogger(ExifToolNew3.class); + + private final Map featureSupportedMap = new HashMap(); + private final Set featureEnabledSet = EnumSet.noneOf(Feature.class); + private final ReadOptions defReadOptions; + private WriteOptions defWriteOptions = new WriteOptions(); + private final VersionNumber exifVersion; + private final ExifProxy exifProxy; + + public ExifToolNew() { + this((Feature[]) null); + } + + /** + * In this constructor, exifToolPath and processCleanupDelay are read from system properties exiftool.path and + * exiftool.processCleanupDelay. processCleanupDelay is optional. If not found, the default is used. + */ + public ExifToolNew(Feature... features) { + this(new ReadOptions(), features); + } + + public ExifToolNew(long cleanupDelayInMillis, Feature... features) { + this(new ReadOptions(), cleanupDelayInMillis, features); + } + + public ExifToolNew(ReadOptions readOptions, Feature... features) { + this(readOptions, Long.getLong(ENV_EXIF_TOOL_PROCESSCLEANUPDELAY, DEFAULT_PROCESS_CLEANUP_DELAY), features); + } + + public ExifToolNew(ReadOptions readOptions, long cleanupDelayInMillis, Feature... features) { + this(System.getProperty(ENV_EXIF_TOOL_PATH, "exiftool"), cleanupDelayInMillis, readOptions, features); + } + + /** + * Pass in the absolute path to the ExifToolNew3 executable on the host system. + */ + public ExifToolNew(String exifToolPath) { + this(exifToolPath, DEFAULT_PROCESS_CLEANUP_DELAY, new ReadOptions()); + } + + public ExifToolNew(String exifToolPath, Feature... features) { + this(exifToolPath, DEFAULT_PROCESS_CLEANUP_DELAY, new ReadOptions(), features); + } + + public ExifToolNew(String exifCmd, long processCleanupDelay, ReadOptions readOptions, Feature... features) { + this.exifVersion = ExifProcess.readVersion(exifCmd); + this.defReadOptions = readOptions; + if (features != null && features.length > 0) { + for (Feature feature : features) { + if (!feature.isSupported(exifVersion)) { + throw new UnsupportedFeatureException(feature); + } + this.featureEnabledSet.add(feature); + this.featureSupportedMap.put(feature, true); + } + } + + List baseArgs = new ArrayList(3); + if (featureEnabledSet.contains(Feature.MWG_MODULE)) { + baseArgs.addAll(Arrays.asList("-use", "MWG")); + } + if (featureEnabledSet.contains(Feature.STAY_OPEN)) { + KeepAliveExifProxy proxy = new KeepAliveExifProxy(exifCmd, baseArgs, processCleanupDelay, + ExifToolNew3.computeDefaultCharset(featureEnabledSet)); + exifProxy = proxy; + } else { + if (processCleanupDelay != 0) { + throw new RuntimeException( + "The processCleanupDelay parameter should be 0 if no stay_open parameter is used. Was " + + processCleanupDelay); + } + exifProxy = new SingleUseExifProxy(exifCmd, baseArgs, ExifToolNew3.computeDefaultCharset(featureEnabledSet)); + } + } + + // + // /** + // * Limits the amount of time (in mills) an exif operation can take. + // Setting + // * value to greater than 0 to enable. + // */ + // public ExifToolNew setRunTimeout(long mills) { + // defReadOptions = defReadOptions.withRunTimeoutMills(mills); + // defWriteOptions = defWriteOptions.withRunTimeoutMills(mills); + // return this; + // } + + /** + * Used to determine if the given {@link Feature} is supported by the underlying native install of ExifToolNew3 + * pointed at by {@link #ENV_EXIF_TOOL_PATH}. + *

+ * If support for the given feature has not been checked for yet, this method will automatically call out to + * ExifToolNew3 and ensure the requested feature is supported in the current local install. + *

+ * The external call to ExifToolNew3 to confirm feature support is only ever done once per JVM session and stored in + * a static final {@link Map} that all instances of this class share. + * + * @param feature + * The feature to check support for in the underlying ExifToolNew3 install. + * + * @return true if support for the given {@link Feature} was confirmed to work with the currently + * installed ExifToolNew3 or false if it is not supported. + * + * @throws IllegalArgumentException + * if feature is null. + * @throws RuntimeException + * if any exception occurs while attempting to start the external ExifToolNew3 process to verify feature + * support. + */ + public boolean isFeatureSupported(Feature feature) throws RuntimeException { + if (feature == null) { + throw new IllegalArgumentException("feature cannot be null"); + } + + Boolean supported = featureSupportedMap.get(feature); + + /* + * If there is no Boolean flag for the feature, support for it hasn't been checked yet with the native + * ExifToolNew3 install, so we need to do that. + */ + if (supported == null) { + log.debug("Support for feature %s has not been checked yet, checking..."); + supported = feature.isSupported(exifVersion); + featureSupportedMap.put(feature, supported); + } + + return supported; + } + + /** + * Used to determine if the given {@link Feature} has been enabled for this particular instance of + * {@link ExifToolNew3}. + *

+ * This method is different from {@link #isFeatureSupported(Feature)}, which checks if the given feature is + * supported by the underlying ExifToolNew3 install where as this method tells the caller if the given feature has + * been enabled for use in this particular instance. + * + * @param feature + * The feature to check if it has been enabled for us or not on this instance. + * + * @return true if the given {@link Feature} is currently enabled on this instance of + * {@link ExifToolNew3}, otherwise returns false. + * + * @throws IllegalArgumentException + * if feature is null. + */ + public boolean isFeatureEnabled(Feature feature) throws IllegalArgumentException { + if (feature == null) { + throw new IllegalArgumentException("feature cannot be null"); + } + return featureEnabledSet.contains(feature); + } + +// /** +// * Used to startup the external ExifToolNew3 process and open the read/write streams used to communicate with it +// * when {@link Feature#STAY_OPEN} is enabled. This method has no effect if the stay open feature is not enabled. +// */ +// public void startup() { +// exifProxy.startup(); +// } + + /** + * This is same as {@link #close()}, added for consistency with {@link #startup()} + */ + public void shutdown() { + close(); + } + + /** + * Used to shutdown the external ExifToolNew3 process and close the read/write streams used to communicate with it + * when {@link Feature#STAY_OPEN} is enabled. + *

+ * NOTE: Calling this method does not preclude this instance of {@link ExifToolNew3} from being + * re-used, it merely disposes of the native and internal resources until the next call to getImageMeta + * causes them to be re-instantiated. + *

+ * Calling this method on an instance of this class without {@link Feature#STAY_OPEN} support enabled has no effect. + */ + public void close() { + exifProxy.shutdown(); + } + + public boolean isStayOpen() { + return featureEnabledSet.contains(Feature.STAY_OPEN); + } + + /** + * For {@link ExifToolNew3} instances with {@link Feature#STAY_OPEN} support enabled, this method is used to + * determine if there is currently a running ExifToolNew3 process associated with this class. + *

+ * Any dependent processes and streams can be shutdown using {@link #close()} and this class will automatically + * re-create them on the next call to getImageMeta if necessary. + * + * @return true if there is an external ExifToolNew3 process in daemon mode associated with this class + * utilizing the {@link Feature#STAY_OPEN} feature, otherwise returns false. + */ + public boolean isRunning() { + return exifProxy != null && !exifProxy.isRunning(); + } + + public ReadOptions getReadOptions() { + return defReadOptions; + } + + public WriteOptions getWriteOptions() { + return defWriteOptions; + } + + public ExifToolNew setWriteOptions(WriteOptions options) { + defWriteOptions = options; + return this; + } + + public void addImageMetadata(File image, Map values) throws IOException { + writeMetadata(defWriteOptions.withDeleteBackupFile(false), image, values); + } + + @Override + public Map getImageMeta(File file, ReadOptions options, String... tags) throws IOException { + if (file == null) { + throw new IllegalArgumentException("file cannot be null and must be a valid stream of image data."); + } + if (!file.canRead()) { + throw new SecurityException( + "Unable to read the given image [" + + file.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to read it."); + } + + List args = new ArrayList(tags.length + 2); + if (options.numericOutput) { + args.add("-n"); // numeric output + } + if (options.showDuplicates) { + args.add("-a"); + } + if (!options.showEmptyTags) { + args.add("-S"); // compact output + } + for (String tag : tags) { + args.add("-" + tag); + } + args.add(file.getAbsolutePath()); + + Map resultMap = ExifToolService.toMap(exifProxy.execute(options.runTimeoutMills, args)); + return resultMap; + } + + public void writeMetadata(File image, Map values) throws IOException { + writeMetadata(defWriteOptions, image, values); + } + + /** + * Takes a map of tags (either (@link Tag) or Strings for keys) and replaces/appends them to the metadata. + */ + public void writeMetadata(WriteOptions options, File image, Map values) throws IOException { + if (image == null) { + throw new IllegalArgumentException("image cannot be null and must be a valid stream of image data."); + } + if (!image.exists()) + throw new FileNotFoundException(String.format("File \"%s\" does not exits", image.getAbsolutePath())); + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values cannot be null and must contain 1 or more tag to value mappings"); + } + + if (!image.canWrite()) { + throw new SecurityException( + "Unable to write the given image [" + + image.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to write to it."); + } + log.info("Adding Tags {} to {}", values, image.getAbsolutePath()); + + // start process + long startTime = System.currentTimeMillis(); + execute(options, image, values); + + // Print out how long the call to external ExifToolNew3 process took. + if (log.isDebugEnabled()) { + log.debug(String.format("Image Meta Processed in %d ms [added %d tags]", + (System.currentTimeMillis() - startTime), values.size())); + } + } + + private void execute(WriteOptions options, File image, Map values) throws IOException { + List args = new ArrayList(values.size() + 4); + if (options.ignoreMinorErrors) { + args.add("-ignoreMinorErrors"); + } + + for (Map.Entry entry : values.entrySet()) { + args.addAll(serializeToArgs(entry.getKey(), entry.getValue())); + } + args.add(image.getAbsolutePath()); + + try { + exifProxy.execute(options.runTimeoutMills, args); + } finally { + if (options.deleteBackupFile) { + File origBackup = new File(image.getAbsolutePath() + "_original"); + if (origBackup.exists()) + origBackup.delete(); + } + } + } + + public void rebuildMetadata(File file) throws IOException { + rebuildMetadata(getWriteOptions(), file); + } + + /** + * Rewrite all the the metadata tags in a JPEG image. This will not work for TIFF files. Use this when the image has + * some corrupt tags. + * + * @link http://www.sno.phy.queensu.ca/~phil/exiftool/faq.html#Q20 + */ + public void rebuildMetadata(WriteOptions options, File file) throws IOException { + if (file == null) + throw new NullPointerException("File is null"); + if (!file.exists()) + throw new FileNotFoundException(String.format("File \"%s\" does not exits", file.getAbsolutePath())); + if (!file.canWrite()) + throw new SecurityException(String.format("File \"%s\" cannot be written to", file.getAbsolutePath())); + + List args = Arrays.asList("-all=", "-tagsfromfile", "@", "-all:all", "-unsafe", file.getAbsolutePath()); + try { + exifProxy.execute(options.runTimeoutMills, args); + } finally { + if (options.deleteBackupFile) { + File origBackup = new File(file.getAbsolutePath() + "_original"); + if (origBackup.exists()) + origBackup.delete(); + } + } + } + + // ================================================================================ + // STATIC helpers + + static List serializeToArgs(Object tag, Object value) { + final Class tagType; + final String tagName; + if (tag instanceof MetadataTag) { + tagName = ((MetadataTag) tag).getKey(); + tagType = ((MetadataTag) tag).getType(); + } else { + tagName = tag.toString(); + tagType = null; + } + + // pre process + if (value != null) { + if (value.getClass().isArray()) { + // convert array to iterable, this is lame + int len = Array.getLength(value); + List newList = new ArrayList(len); + for (int i = 0; i < len; i++) { + Object item = Array.get(value, i); + newList.add(item); + } + value = newList; + } else if (value instanceof Number && Date.class.equals(tagType)) { + // if we know this is a date field and data is a number assume + // it is unix epoch time + Date date = new Date(((Number) value).longValue()); + value = date; + } + } + + List args = new ArrayList(4); + String arg; + if (value == null) { + arg = String.format("-%s=", tagName); + } else if (value instanceof Number) { + arg = String.format("-%s#=%s", tagName, value); + } else if (value instanceof Date) { + SimpleDateFormat formatter = new SimpleDateFormat(EXIF_DATE_FORMAT); + arg = String.format("-%s=%s", tagName, formatter.format((Date) value)); + } else if (value instanceof Iterable) { + Iterable it = (Iterable) value; + args.add("-sep"); + args.add(","); + StringBuilder itemList = new StringBuilder(); + for (Object item : it) { + if (itemList.length() > 0) { + itemList.append(","); + } + itemList.append(item); + } + arg = String.format("-%s=%s", tagName, itemList); + } else { + if (tagType != null && tagType.isArray()) { + args.add("-sep"); + args.add(","); + } + arg = String.format("-%s=%s", tagName, value); + } + args.add(arg); + return args; + } + + @Override + protected void finalize() throws Throwable { + log.info("ExifToolNew3 not used anymore shutdown the exiftool process..."); + shutdown(); + super.finalize(); + } + + @Override + public List execute(List args) { + return exifProxy.execute(defReadOptions.runTimeoutMills, args); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew2.java b/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew2.java new file mode 100644 index 00000000..ea585d78 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew2.java @@ -0,0 +1,988 @@ +/** + * Copyright 2011 The Buzz Media, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.thebuzzmedia.exiftool; + +import java.io.*; +import java.util.*; +import java.util.Map.Entry; +import java.util.regex.Pattern; + +import com.thebuzzmedia.exiftool.adapters.ExifToolService; + +/** + * Class used to provide a Java-like interface to Phil Harvey's excellent, Perl-based ExifToolNew3. + *

+ * There are a number of other basic Java wrappers to ExifToolNew3 available online, but most of them only abstract out + * the actual Java-external-process execution logic and do no additional work to make integration with the external + * ExifToolNew3 any easier or intuitive from the perspective of the Java application written to make use of + * ExifToolNew3. + *

+ * This class was written in order to make integration with ExifToolNew3 inside of a Java application seamless and + * performant with the goal being that the developer can treat ExifToolNew3 as if it were written in Java, garnering all + * of the benefits with none of the added headache of managing an external native process from Java. + *

+ * Phil Harvey's ExifToolNew3 is written in Perl and runs on all major platforms (including Windows) so no portability + * issues are introduced into your application by utilizing this class. + *

Usage

+ * Assuming ExifToolNew3 is installed on the host system correctly and either in the system path or pointed to by + * {@link #EXIF_TOOL_PATH}, using this class to communicate with ExifToolNew3 is as simple as creating an instance ( + * ExifToolNew3 tool = new ExifToolNew3()) and then making calls to + * {@link #getImageMeta3(File, ReadOptions, Tag...)} or {@link #getImageMeta4(File, ReadOptions, Format, Tag...)} with a + * list of {@link Tag}s you want to pull values for from the given image. + *

+ * In this default mode, calls to getImageMeta will automatically start an external ExifToolNew3 process to + * handle the request. After ExifToolNew3 has parsed the tag values from the file, the external process exits and this + * class parses the result before returning it to the caller. + *

+ * Results from calls to getImageMeta are returned in a {@link Map} with the {@link Tag} values as the keys + * and {@link String} values for every tag that had a value in the image file as the values. {@link Tag}s with no value + * found in the image are omitted from the result map. + *

+ * While each {@link Tag} provides a hint at which format the resulting value for that tag is returned as from + * ExifToolNew3 (see {@link Tag#getType()}), that only applies to values returned with an output format of + * {@link Format#NUMERIC} and it is ultimately up to the caller to decide how best to parse or convert the returned + * values. + *

+ * The {@link Tag} Enum provides the {@link Tag#parseValue(Tag, String)} convenience method for parsing given + * String values according to the Tag hint automatically for you if that is what you plan on doing, + * otherwise feel free to handle the return values anyway you want. + *

ExifToolNew3 -stay_open Support

+ * ExifToolNew3 8.36 added a new persistent-process feature that allows ExifToolNew3 to stay running in a daemon mode and + * continue accepting commands via a file or stdin. + *

+ * This new mode is controlled via the -stay_open True/False command line argument and in a busy system + * that is making thousands of calls to ExifToolNew3, can offer speed improvements of up to 60x (yes, + * really that much). + *

+ * This feature was added to ExifToolNew3 shortly after user Christian + * Etter discovered the overhead for starting up a new Perl interpreter each time ExifToolNew3 is loaded accounts + * for roughly 98.4% of + * the total runtime. + *

+ * Support for using ExifToolNew3 in daemon mode is enabled by passing {@link Feature#STAY_OPEN} to the constructor of + * the class when creating an instance of this class and then simply using the class as you normally would. This class + * will manage a single ExifToolNew3 process running in daemon mode in the background to service all future calls to the + * class. + *

+ * Because this feature requires ExifToolNew3 8.36 or later, this class will actually verify support for the feature in + * the version of ExifToolNew3 pointed at by {@link #EXIF_TOOL_PATH} before successfully instantiating the class and + * will notify you via an {@link UnsupportedFeatureException} if the native ExifToolNew3 doesn't support the requested + * feature. + *

+ * In the event of an {@link UnsupportedFeatureException}, the caller can either upgrade the native ExifToolNew3 upgrade + * to the version required or simply avoid using that feature to work around the exception. + *

Automatic Resource Cleanup

+ * When {@link Feature#STAY_OPEN} mode is used, there is the potential for leaking both host OS processes (native + * 'exiftool' processes) as well as the read/write streams used to communicate with it unless {@link #close()} is called + * to clean them up when done. Fortunately, this class provides an automatic cleanup mechanism that + * runs, by default, after 10mins of inactivity to clean up those stray resources. + *

+ * The inactivity period can be controlled by modifying the {@link #PROCESS_CLEANUP_DELAY} system variable. A value of + * 0 or less disabled the automatic cleanup process and requires you to cleanup ExifToolNew3 instances on + * your own by calling {@link #close()} manually. + *

+ * Any class activity by way of calls to getImageMeta will always reset the inactivity timer, so in a busy + * system the cleanup thread could potentially never run, leaving the original host ExifToolNew3 process running forever + * (which is fine). + *

+ * This design was chosen to help make using the class and not introducing memory leaks and bugs into your code easier + * as well as making very inactive instances of this class light weight while not in-use by cleaning up after + * themselves. + *

+ * The only overhead incurred when opening the process back up is a 250-500ms lag while launching the VM interpreter + * again on the first call (depending on host machine speed and load). + *

Reusing a "closed" ExifToolNew3 Instance

+ * If you or the cleanup thread have called {@link #close()} on an instance of this class, cleaning up the host process + * and read/write streams, the instance of this class can still be safely used. Any followup calls to + * getImageMeta will simply re-instantiate all the required resources necessary to service the call + * (honoring any {@link Feature}s set). + *

+ * This can be handy behavior to be aware of when writing scheduled processing jobs that may wake up every hour and + * process thousands of pictures then go back to sleep. In order for the process to execute as fast as possible, you + * would want to use ExifToolNew3 in daemon mode (pass {@link Feature#STAY_OPEN} to the constructor of this class) and + * when done, instead of {@link #close()}-ing the instance of this class and throwing it out, you can keep the reference + * around and re-use it again when the job executes again an hour later. + *

Performance

+ * Extra care is taken to ensure minimal object creation or unnecessary CPU overhead while communicating with the + * external process. + *

+ * {@link Pattern}s used to split the responses from the process are explicitly compiled and reused, string + * concatenation is minimized, Tag name lookup is done via a static final {@link Map} shared by all + * instances and so on. + *

+ * Additionally, extra care is taken to utilize the most optimal code paths when initiating and using the external + * process, for example, the {@link ProcessBuilder#command(List)} method is used to avoid the copying of array elements + * when {@link ProcessBuilder#command(String...)} is used and avoiding the (hidden) use of {@link StringTokenizer} when + * {@link Runtime#exec(String)} is called. + *

+ * All of this effort was done to ensure that imgscalr and its supporting classes continue to provide best-of-breed + * performance and memory utilization in long running/high performance environments (e.g. web applications). + *

Thread Safety

+ * Instances of this class are not Thread-safe. Both the instance of this class and external + * ExifToolNew3 process maintain state specific to the current operation. Use of instances of this class need to be + * synchronized using an external mechanism or in a highly threaded environment (e.g. web application), instances of + * this class can be used along with {@link ThreadLocal}s to ensure Thread-safe, highly parallel use. + *

Why ExifToolNew3?

+ * ExifToolNew3 is written in Perl and requires an external + * process call from Java to make use of. + *

+ * While this would normally preclude a piece of software from inclusion into the imgscalr library (more complex + * integration), there is no other image metadata piece of software available as robust, complete and well-tested as + * ExifToolNew3. In addition, ExifToolNew3 already runs on all major platforms (including Windows), so there was not a + * lack of portability introduced by providing an integration for it. + *

+ * Allowing it to be used from Java is a boon to any Java project that needs the ability to read/write image-metadata + * from almost any image or video file format. + *

Alternatives

+ * If integration with an external Perl process is something your app cannot do and you still need image + * metadata-extraction capability, Drew Noakes has written the 2nd most robust image metadata library I have come + * across: Metadata Extractor that you might want to look + * at. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +public class ExifToolNew2 implements RawExifTool { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(ExifToolNew2.class); + /** + * Flag used to indicate if debugging output has been enabled by setting the "exiftool.debug" system + * property to true. This value will be false if the " exiftool.debug" system + * property is undefined or set to false. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.debug=true + * or by calling {@link System#setProperty(String, String)} before this class is loaded. + *

+ * Default value is false. + */ + public static final Boolean DEBUG = Boolean.getBoolean("exiftool.debug"); + + /** + * Prefix to every log message this library logs. Using a well-defined prefix helps make it easier both visually and + * programmatically to scan log files for messages produced by this library. + *

+ * The value is "[exiftool] " (including the space). + */ + public static final String LOG_PREFIX = "[exiftool] "; + + /** + * The absolute path to the ExifToolNew3 executable on the host system running this class as defined by the " + * exiftool.path" system property. + *

+ * If ExifToolNew3 is on your system path and running the command "exiftool" successfully executes it, leaving this + * value unchanged will work fine on any platform. If the ExifToolNew3 executable is named something else or not in + * the system path, then this property will need to be set to point at it before using this class. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.path=/path/to/exiftool + * or by calling {@link System#setProperty(String, String)} before this class is loaded. + *

+ * On Windows be sure to double-escape the path to the tool, for example: + * -Dexiftool.path=C:\\Tools\\exiftool.exe + * + *

+ * Default value is "exiftool". + *

Relative Paths

+ * Relative path values (e.g. "bin/tools/exiftool") are executed with relation to the base directory the VM process + * was started in. Essentially the directory that new File(".").getAbsolutePath() points at during + * runtime. + */ + public static final String EXIF_TOOL_PATH = System.getProperty("exiftool.path", "exiftool"); + + /** + * Interval (in milliseconds) of inactivity before the cleanup thread wakes up and cleans up the daemon ExifToolNew3 + * process and the read/write streams used to communicate with it when the {@link Feature#STAY_OPEN} feature is + * used. + *

+ * Ever time a call to getImageMeta is processed, the timer keeping track of cleanup is reset; more + * specifically, this class has to experience no activity for this duration of time before the cleanup process is + * fired up and cleans up the host OS process and the stream resources. + *

+ * Any subsequent calls to getImageMeta after a cleanup simply re-initializes the resources. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.processCleanupDelay=600000 + * or by calling {@link System#setProperty(String, String)} before this class is loaded. + *

+ * Setting this value to 0 disables the automatic cleanup thread completely and the caller will need to manually + * cleanup the external ExifToolNew3 process and read/write streams by calling {@link #close()}. + *

+ * Default value is 600,000 (10 minutes). + */ + public static final long PROCESS_CLEANUP_DELAY = Long.getLong("exiftool.processCleanupDelay", 600000); + + /** + * Name used to identify the (optional) cleanup {@link Thread}. + *

+ * This is only provided to make debugging and profiling easier for implementors making use of this class such that + * the resources this class creates and uses (i.e. Threads) are readily identifiable in a running VM. + *

+ * Default value is "ExifToolNew3 Cleanup Thread". + */ + protected static final String CLEANUP_THREAD_NAME = "ExifToolNew3 Cleanup Thread"; + + /** + * Compiled {@link Pattern} of ": " used to split compact output from ExifToolNew3 evenly into name/value pairs. + */ + protected static final Pattern TAG_VALUE_PATTERN = Pattern.compile(": "); + + /** + * Map shared across all instances of this class that maintains the state of {@link Feature}s and if they are + * supported or not (supported=true, unsupported=false) by the underlying native ExifToolNew3 process being used in + * conjunction with this class. + *

+ * If a {@link Feature} is missing from the map (has no true or false flag associated with + * it, but null instead) then that means that feature has not been checked for support yet and this + * class will know to call {@link #checkFeatureSupport(Feature...)} on it to determine its supported state. + *

+ * For efficiency reasons, individual {@link Feature}s are checked for support one time during each run of the VM + * and never again during the session of that running VM. + */ + protected static final Map FEATURE_SUPPORT_MAP = new HashMap(); + + /** + * Static list of args used to execute ExifToolNew3 using the '-ver' flag in order to get it to print out its + * version number. Used by the {@link #checkFeatureSupport(Feature...)} method to check all the required feature + * versions. + *

+ * Defined here as a static final list because it is used every time and never changes. + */ + private static final List VERIFY_FEATURE_ARGS = new ArrayList(2); + + static { + VERIFY_FEATURE_ARGS.add(EXIF_TOOL_PATH); + VERIFY_FEATURE_ARGS.add("-ver"); + } + + /** + * Used to determine if the given {@link Feature} is supported by the underlying native install of ExifToolNew3 + * pointed at by {@link #EXIF_TOOL_PATH}. + *

+ * If support for the given feature has not been checked for yet, this method will automatically call out to + * ExifToolNew3 and ensure the requested feature is supported in the current local install. + *

+ * The external call to ExifToolNew3 to confirm feature support is only ever done once per JVM session and stored in + * a static final {@link Map} that all instances of this class share. + * + * @param feature + * The feature to check support for in the underlying ExifToolNew3 install. + * + * @return true if support for the given {@link Feature} was confirmed to work with the currently + * installed ExifToolNew3 or false if it is not supported. + * + * @throws IllegalArgumentException + * if feature is null. + * @throws RuntimeException + * if any exception occurs while attempting to start the external ExifToolNew3 process to verify feature + * support. + */ + + @Override + public boolean isFeatureSupported(Feature feature) throws IllegalArgumentException, RuntimeException { + if (feature == null) + throw new IllegalArgumentException("feature cannot be null"); + + Boolean supported = FEATURE_SUPPORT_MAP.get(feature); + + /* + * If there is no Boolean flag for the feature, support for it hasn't been checked yet with the native + * ExifToolNew3 install, so we need to do that. + */ + if (supported == null) { + log("\tSupport for feature %s has not been checked yet, checking..."); + checkFeatureSupport(feature); + + // Re-query for the supported state + supported = FEATURE_SUPPORT_MAP.get(feature); + } + + return supported; + } + + /** + * Helper method used to ensure a message is loggable before it is logged and then pre-pend a universal prefix to + * all log messages generated by this library to make the log entries easy to parse visually or programmatically. + *

+ * If a message cannot be logged (logging is disabled) then this method returns immediately. + *

+ * NOTE: Because Java will auto-box primitive arguments into Objects when building out the + * params array, care should be taken not to call this method with primitive values unless + * {@link #DEBUG} is true; otherwise the VM will be spending time performing unnecessary auto-boxing + * calculations. + * + * @param message + * The log message in format string + * syntax that will be logged. + * @param params + * The parameters that will be swapped into all the place holders in the original messages before being + * logged. + * + * @see #LOG_PREFIX + */ + protected static void log(String message, Object... params) { + if (DEBUG) + LOG.debug(LOG_PREFIX + message + '\n', params); + } + + /** + * Used to verify the version of ExifToolNew3 installed is a high enough version to support the given features. + *

+ * This method runs the command "exiftool -ver" to get the version of the installed ExifToolNew3 and + * then compares that version to the least required version specified by the given features (see + * {@link Feature#getVersion()}). + * + * @param features + * The features whose required versions will be checked against the installed ExifToolNew3 for support. + * + * @throws RuntimeException + * if any exception occurs communicating with the external ExifToolNew3 process spun up in order to + * check its version. + */ + protected static void checkFeatureSupport(Feature... features) throws RuntimeException { + // Ensure there is work to do. + if (features == null || features.length == 0) + return; + + log("\tChecking %d feature(s) for support in the external ExifToolNew3 install...", features.length); + + for (int i = 0; i < features.length; i++) { + String ver = null; + Boolean supported; + Feature feature = features[i]; + + log("\t\tChecking feature %s for support, requires ExifToolNew3 version %s or higher...", feature, + feature.getVersion()); + + // Execute 'exiftool -ver' + IOStream streams = startExifToolProcess(VERIFY_FEATURE_ARGS); + + try { + // Read the single-line reply (version number) + ver = streams.reader.readLine(); + } catch (Exception e) { + /* + * no-op, while it is important to know that we COULD launch the ExifToolNew3 process (i.e. + * startExifToolProcess call worked) but couldn't communicate with it, the context with which this + * method is called is from the constructor of this class which would just wrap this exception and + * discard it anyway if it failed. + * + * the caller will realize there is something wrong with the ExifToolNew3 process communication as soon + * as they make their first call to getImageMeta in which case whatever was causing the exception here + * will popup there and then need to be corrected. + * + * This is an edge case that should only happen in really rare scenarios, so making this method easier + * to use is more important that robust IOException handling right here. + */ + } finally { + // Close r/w streams to exited process. + streams.close(); + } + + // Ensure the version found is >= the required version. + if (ver != null && ver.compareTo(feature.getVersion().toString()) >= 0) { + supported = Boolean.TRUE; + log("\t\tFound ExifToolNew3 version %s, feature %s is SUPPORTED.", ver, feature); + } else { + supported = Boolean.FALSE; + log("\t\tFound ExifToolNew3 version %s, feature %s is NOT SUPPORTED.", ver, feature); + } + + // Update feature support map + FEATURE_SUPPORT_MAP.put(feature, supported); + } + } + + protected static IOStream startExifToolProcess(List args) throws RuntimeException { + Process proc = null; + IOStream streams = null; + + log("\tAttempting to start external ExifToolNew3 process using args: %s", args); + + try { + proc = new ProcessBuilder(args).start(); + log("\t\tSuccessful"); + } catch (Exception e) { + String message = "Unable to start external ExifToolNew3 process using the execution arguments: " + args + + ". Ensure ExifToolNew3 is installed correctly and runs using the command path '" + EXIF_TOOL_PATH + + "' as specified by the 'exiftool.path' system property."; + + log(message); + throw new RuntimeException(message, e); + } + + log("\tSetting up Read/Write streams to the external ExifToolNew3 process..."); + + // Setup read/write streams to the new process. + streams = new IOStream(new BufferedReader(new InputStreamReader(proc.getInputStream())), + new OutputStreamWriter(proc.getOutputStream())); + + log("\t\tSuccessful, returning streams to caller."); + return streams; + } + + private Timer cleanupTimer; + private TimerTask currentCleanupTask; + + IOStream streams; + List args; + + Set featureSet; + + public ExifToolNew2() { + this((Feature[]) null); + } + + public ExifToolNew2(Feature... features) throws UnsupportedFeatureException { + featureSet = new HashSet(); + + if (features != null && features.length > 0) { + /* + * Process all features to ensure we checked them for support in the installed version of ExifToolNew3. If + * the feature has already been checked before, this method will return immediately. + */ + checkFeatureSupport(features); + + /* + * Now we need to verify that all the features requested for this instance of ExifToolNew3 to use WERE + * supported after all. + */ + for (int i = 0; i < features.length; i++) { + Feature f = features[i]; + + /* + * If the Feature was supported, record it in the local featureSet so this instance knows what features + * are being turned on by the caller. + * + * If the Feature was not supported, throw an exception reporting it to the caller so they know it + * cannot be used. + */ + if (FEATURE_SUPPORT_MAP.get(f).booleanValue()) + featureSet.add(f); + else + throw new UnsupportedFeatureException(f); + } + } + + args = new ArrayList(64); + + /* + * Now that initialization is done, init the cleanup timer if we are using STAY_OPEN and the delay time set is + * non-zero. + */ + if (isFeatureEnabled(Feature.STAY_OPEN) && PROCESS_CLEANUP_DELAY > 0) { + this.cleanupTimer = new Timer(CLEANUP_THREAD_NAME, true); + + // Start the first cleanup task counting down. + resetCleanupTask(); + } + } + + public void shutdownCleanupTask() { + if (currentCleanupTask != null) { + currentCleanupTask.cancel(); + } + currentCleanupTask = null; + if (cleanupTimer != null) { + cleanupTimer.cancel(); + } + } + + /** + * Used to shutdown the external ExifToolNew3 process and close the read/write streams used to communicate with it + * when {@link Feature#STAY_OPEN} is enabled. + *

+ * NOTE: Calling this method does not preclude this instance of {@link ExifToolNew3} from being + * re-used, it merely disposes of the native and internal resources until the next call to getImageMeta + * causes them to be re-instantiated. + *

+ * The cleanup thread will automatically call this after an interval of inactivity defined by + * {@link #PROCESS_CLEANUP_DELAY}. + *

+ * Calling this method on an instance of this class without {@link Feature#STAY_OPEN} support enabled has no effect. + */ + public void close() { + /* + * no-op if the underlying process and streams have already been closed OR if stayOpen was never used in the + * first place in which case nothing is open right now anyway. + */ + if (streams == null) + return; + + /* + * If ExifToolNew3 was used in stayOpen mode but getImageMeta was never called then the streams were never + * initialized and there is nothing to shut down or destroy, otherwise we need to close down all the resources + * in use. + */ + if (streams == null) { + log("\tThis ExifToolNew3 instance was never used so no external process or streams were ever created (nothing to clean up, we will just exit)."); + } else { + try { + log("\tAttempting to close ExifToolNew3 daemon process, issuing '-stay_open\\nFalse\\n' command..."); + + // Tell the ExifToolNew3 process to exit. + streams.writer.write("-stay_open\nFalse\n"); + streams.writer.flush(); + + log("\t\tSuccessful"); + } catch (IOException e) { + e.printStackTrace(); + } finally { + streams.close(); + } + } + + streams = null; + log("\tExifTool daemon process successfully terminated."); + } + + /** + * For {@link ExifToolNew3} instances with {@link Feature#STAY_OPEN} support enabled, this method is used to + * determine if there is currently a running ExifToolNew3 process associated with this class. + *

+ * Any dependent processes and streams can be shutdown using {@link #close()} and this class will automatically + * re-create them on the next call to getImageMeta if necessary. + * + * @return true if there is an external ExifToolNew3 process in daemon mode associated with this class + * utilizing the {@link Feature#STAY_OPEN} feature, otherwise returns false. + */ + public boolean isRunning() { + return (streams != null); + } + + /** + * Used to determine if the given {@link Feature} has been enabled for this particular instance of + * {@link ExifToolNew3}. + *

+ * This method is different from {@link #isFeatureSupported(Feature)}, which checks if the given feature is + * supported by the underlying ExifToolNew3 install where as this method tells the caller if the given feature has + * been enabled for use in this particular instance. + * + * @param feature + * The feature to check if it has been enabled for us or not on this instance. + * + * @return true if the given {@link Feature} is currently enabled on this instance of + * {@link ExifToolNew3}, otherwise returns false. + * + * @throws IllegalArgumentException + * if feature is null. + */ + public boolean isFeatureEnabled(Feature feature) throws IllegalArgumentException { + if (feature == null) + throw new IllegalArgumentException("feature cannot be null"); + + return featureSet.contains(feature); + } + @Override + public Map getImageMeta(File image, ReadOptions options, String... tags) + throws IllegalArgumentException, SecurityException, IOException { + return ExifToolService.toMap(execute3(image,options,tags)); + } + private List execute3(File image, ReadOptions options, String... tags) { + try { + return execute2(image,options,tags); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + private List execute2(File image, ReadOptions options, String... tags) throws IOException { + if (image == null) + throw new IllegalArgumentException("image cannot be null and must be a valid stream of image data."); + if (options == null) + throw new IllegalArgumentException("options cannot be null"); + // if (tags == null) { + // tags = new MetadataTag[0]; + // } + if (tags == null || tags.length == 0) + throw new IllegalArgumentException( + "tags cannot be null and must contain 1 or more Tag to query the image for."); + if (!image.canRead()) + throw new SecurityException( + "Unable to read the given image [" + + image.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to read it."); + + long startTime = System.currentTimeMillis(); + + if (DEBUG) + log("Querying %d tags from image: %s", tags.length, image.getAbsolutePath()); + + long exifToolCallElapsedTime = 0; + + /* + * Using ExifToolNew3 in daemon mode (-stay_open True) executes different code paths below. So establish the + * flag for this once and it is reused a multitude of times later in this method to figure out where to branch + * to. + */ + boolean stayOpen = isStayOpen(); + + // Clear process args + args.clear(); + + if (stayOpen) { + log("\tUsing ExifToolNew3 in daemon mode (-stay_open True)..."); + + // Always reset the cleanup task. + resetCleanupTask(); + + /* + * If this is our first time calling getImageMeta with a stayOpen connection, set up the persistent process + * and run it so it is ready to receive commands from us. + */ + if (streams == null) { + log("\tStarting daemon ExifToolNew3 process and creating read/write streams (this only happens once)..."); + + args.add(EXIF_TOOL_PATH); + args.add("-stay_open"); + args.add("True"); + args.add("-@"); + args.add("-"); + + // Begin the persistent ExifToolNew3 process. + streams = startExifToolProcess(args); + } + + log("\tStreaming arguments to ExifToolNew3 process..."); + + if (options.numericOutput) + streams.writer.write("-n\n"); // numeric output + + streams.writer.write("-S\n"); // compact output + + for (int i = 0; i < tags.length; i++) { + streams.writer.write('-'); + streams.writer.write(tags[i]); + streams.writer.write("\n"); + } + + streams.writer.write(image.getAbsolutePath()); + streams.writer.write("\n"); + + log("\tExecuting ExifToolNew3..."); + // Run ExifToolNew3 on our file with all the given arguments. + streams.writer.write("-execute\n"); + streams.writer.flush(); + } else { + log("\tUsing ExifToolNew3 in non-daemon mode (-stay_open False)..."); + + /* + * Since we are not using a stayOpen process, we need to setup the execution arguments completely each time. + */ + args.add(EXIF_TOOL_PATH); + + if (options.numericOutput) + args.add("-n"); // numeric output + + args.add("-S"); // compact output + + for (int i = 0; i < tags.length; i++) + args.add("-" + tags[i]); + + args.add(image.getAbsolutePath()); + + // Run the ExifToolNew3 with our args. + streams = startExifToolProcess(args); + + } + + return readResponse(startTime); + } + + private List readResponse(long startTime) + throws IOException { + boolean stayOpen = isStayOpen(); + + // Begin tracking the duration ExifToolNew3 takes to respond. + long exifToolCallElapsedTime = System.currentTimeMillis(); + log("\tReading response back from ExifToolNew3..."); + + String line = null; + /* + * Create a result map big enough to hold results for each of the tags and avoid collisions while inserting. + */ + List resultMap = new ArrayList(); + + while ((line = streams.reader.readLine()) != null) { + resultMap.add(line); +// String[] pair = TAG_VALUE_PATTERN.split(line); +// +// if (pair != null && pair.length == 2) { +// // Determine the tag represented by this value. +// resultMap.put(pair[0], pair[1]); +// // Tag tag = Tag.forName(pair[0]); +// +// /* +// * Store the tag and the associated value in the result map only if we were able to map the name back to +// * a Tag instance. If not, then this is an unknown/unexpected tag return value and we skip it since we +// * cannot translate it back to one of our supported tags. +// */ +// // if (tag != null) { +// // resultMap.put(tag, pair[1]); +// log("\t\tRead Tag [name=%s, value=%s]", pair[0], pair[1]);// tag.getKey(), +// // pair[1]); +// // } +// } + + /* + * When using a persistent ExifToolNew3 process, it terminates its output to us with a "{ready}" clause on a + * new line, we need to look for it and break from this loop when we see it otherwise this process will hang + * indefinitely blocking on the input stream with no data to read. + */ + if (stayOpen && line.equals("{ready}")) + break; + } + + // Print out how long the call to external ExifToolNew3 process took. + log("\tFinished reading ExifToolNew3 response in %d ms.", + (System.currentTimeMillis() - exifToolCallElapsedTime)); + + /* + * If we are not using a persistent ExifToolNew3 process, then after running the command above, the process + * exited in which case we need to clean our streams up since it no longer exists. If we were using a persistent + * ExifToolNew3 process, leave the streams open for future calls. + */ + if (!stayOpen) + streams.close(); + + if (DEBUG) + log("\tImage Meta Processed in %d ms [queried found %d values]", + (System.currentTimeMillis() - startTime), resultMap.size()); + + return resultMap; + } + + public void setImageMeta(File image, Map tags) throws IllegalArgumentException, SecurityException, + IOException { + setImageMeta(image, Format.NUMERIC, tags); + } + + public void setImageMeta(File image, Format format, Map tags) throws IllegalArgumentException, + SecurityException, IOException { + if (image == null) + throw new IllegalArgumentException("image cannot be null and must be a valid stream of image data."); + if (format == null) + throw new IllegalArgumentException("format cannot be null"); + if (tags == null || tags.size() == 0) + throw new IllegalArgumentException( + "tags cannot be null and must contain 1 or more Tag to query the image for."); + if (!image.canWrite()) + throw new SecurityException( + "Unable to read the given image [" + + image.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to read it."); + + long startTime = System.currentTimeMillis(); + + if (DEBUG) + log("Writing %d tags to image: %s", tags.size(), image.getAbsolutePath()); + + long exifToolCallElapsedTime = 0; + + /* + * Using ExifToolNew3 in daemon mode (-stay_open True) executes different code paths below. So establish the + * flag for this once and it is reused a multitude of times later in this method to figure out where to branch + * to. + */ + boolean stayOpen = featureSet.contains(Feature.STAY_OPEN); + + // Clear process args + args.clear(); + + if (stayOpen) { + log("\tUsing ExifToolNew3 in daemon mode (-stay_open True)..."); + + // Always reset the cleanup task. + resetCleanupTask(); + + /* + * If this is our first time calling getImageMeta with a stayOpen connection, set up the persistent process + * and run it so it is ready to receive commands from us. + */ + if (streams == null) { + log("\tStarting daemon ExifToolNew3 process and creating read/write streams (this only happens once)..."); + + args.add(EXIF_TOOL_PATH); + args.add("-stay_open"); + args.add("True"); + args.add("-@"); + args.add("-"); + + // Begin the persistent ExifToolNew3 process. + streams = startExifToolProcess(args); + } + + log("\tStreaming arguments to ExifToolNew3 process..."); + + if (format == Format.NUMERIC) + streams.writer.write("-n\n"); // numeric output + + streams.writer.write("-S\n"); // compact output + + for (Entry entry : tags.entrySet()) { + streams.writer.write('-'); + streams.writer.write(entry.getKey().getKey()); + streams.writer.write("='"); + streams.writer.write(entry.getValue()); + streams.writer.write("'\n"); + } + + streams.writer.write(image.getAbsolutePath()); + streams.writer.write("\n"); + + log("\tExecuting ExifToolNew3..."); + + // Begin tracking the duration ExifToolNew3 takes to respond. + exifToolCallElapsedTime = System.currentTimeMillis(); + + // Run ExifToolNew3 on our file with all the given arguments. + streams.writer.write("-execute\n"); + streams.writer.flush(); + } else { + log("\tUsing ExifToolNew3 in non-daemon mode (-stay_open False)..."); + + /* + * Since we are not using a stayOpen process, we need to setup the execution arguments completely each time. + */ + args.add(EXIF_TOOL_PATH); + + if (format == Format.NUMERIC) + args.add("-n"); // numeric output + + args.add("-S"); // compact output + + for (Entry entry : tags.entrySet()) + args.add("-" + entry.getKey().getKey() + "='" + entry.getValue() + "'"); + + args.add(image.getAbsolutePath()); + + // Run the ExifToolNew3 with our args. + streams = startExifToolProcess(args); + + // Begin tracking the duration ExifToolNew3 takes to respond. + exifToolCallElapsedTime = System.currentTimeMillis(); + } + + log("\tReading response back from ExifToolNew3..."); + + String line = null; + + while ((line = streams.reader.readLine()) != null) { + /* + * When using a persistent ExifToolNew3 process, it terminates its output to us with a "{ready}" clause on a + * new line, we need to look for it and break from this loop when we see it otherwise this process will hang + * indefinitely blocking on the input stream with no data to read. + */ + if (stayOpen && line.equals("{ready}")) + break; + } + + // Print out how long the call to external ExifToolNew3 process took. + log("\tFinished reading ExifToolNew3 response in %d ms.", + (System.currentTimeMillis() - exifToolCallElapsedTime)); + + /* + * If we are not using a persistent ExifToolNew3 process, then after running the command above, the process + * exited in which case we need to clean our streams up since it no longer exists. If we were using a persistent + * ExifToolNew3 process, leave the streams open for future calls. + */ + if (!stayOpen) + streams.close(); + + if (DEBUG) + log("\tImage Meta Processed in %d ms [write %d tags]", (System.currentTimeMillis() - startTime), + tags.size()); + } + + /** + * Helper method used to make canceling the current task and scheduling a new one easier. + *

+ * It is annoying that we cannot just reset the timer on the task, but that isn't the way the java.util.Timer class + * was designed unfortunately. + */ + void resetCleanupTask() { + // no-op if the timer was never created. + if (cleanupTimer == null) + return; + + log("\tResetting cleanup task..."); + + // Cancel the current cleanup task if necessary. + if (currentCleanupTask != null) + currentCleanupTask.cancel(); + + // Schedule a new cleanup task. + cleanupTimer.schedule((currentCleanupTask = new CleanupTimerTask(this)), PROCESS_CLEANUP_DELAY, + PROCESS_CLEANUP_DELAY); + + log("\t\tSuccessful"); + } + +// @Override +// public void startup() { +// resetCleanupTask(); +// } + + @Override + public void shutdown() { + shutdownCleanupTask(); + } + + @Override + public boolean isStayOpen() { + return featureSet.contains(Feature.STAY_OPEN); + } + + @Override + public void addImageMetadata(File image, Map values) throws IOException { + setImageMeta(image, (Map) values); + } + + @Override + @Deprecated + public void writeMetadata(WriteOptions options, File image, Map values) throws IOException { + throw new RuntimeException("Not implemented."); + } + + @Override + protected void finalize() throws Throwable { + LOG.info("ExifToolNew3 not used anymore shutdown the exiftool process..."); + shutdown(); + super.finalize(); + } + + @Override + @Deprecated + public void rebuildMetadata(File file) throws IOException { + throw new RuntimeException("Not implemented."); + } + + @Override + @Deprecated + public void rebuildMetadata(WriteOptions options, File file) throws IOException { + throw new RuntimeException("Not implemented."); + } + + @Override + public List execute(List args) { + //return startExifToolProcess(args); + throw new RuntimeException("Not implemented yet!"); + } +} diff --git a/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew3.java b/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew3.java new file mode 100644 index 00000000..5d3cdc95 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/ExifToolNew3.java @@ -0,0 +1,905 @@ +/** + * Copyright 2011 The Buzz Media, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.thebuzzmedia.exiftool; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; + +import org.apache.commons.io.*; + +import com.google.common.base.CharMatcher; +import com.google.common.base.Joiner; +import com.google.common.collect.Lists; +import com.thebuzzmedia.exiftool.adapters.ExifToolService; + +/* + private def extractExifWithExifToolOld2(prefix: String, file: File): Try[Tags] = + Try { + + def split(text: String): Pair[String, String] = { + val all = text.splitAt(text.indexOf(":")) + Pair(all._1.trim.replaceAll("[/ ]", ""), all._2.drop(1).trim) + } + //println("Coulnd't get exif info from " + file) + import scala.sys.process._ + import scala.sys.process.ProcessIO + val pb = Process(s"""exiftool "${file.getAbsolutePath}"""") + var map = Map[String, String]() + val pio = new ProcessIO(_ => (), + stdout => scala.io.Source.fromInputStream(stdout) + .getLines.foreach { x => + //println(s"found $x") + map += split(x) + }, + _ => ()) + val a = pb.run(pio) + val blockTillExits = a.exitValue + if (blockTillExits == 0) { + //println(map) + //"exiftool".! + //println(map mkString "\n") + val result = map.toMap.map { x => + //println(x) + (prefix + x._1, formatted(x._2)_) + } + Tags(result) + } else { + throw new RuntimeException(s"Coulnd't get exif info from " + file + ". Got $blockTillExits from exiftool.") + } + } + */ +/** + * Provide a Java-like interface to Phil Harvey's excellent, Perl-based ExifToolNew3. + *

+ * Initial work done by "Riyad Kalla" software@thebuzzmedia.com. + *

+ * There are a number of other basic Java wrappers to ExifToolNew3 available online, but most of them only abstract out + * the actual Java-external-process execution logic and do no additional work to make integration with the external + * ExifToolNew3 any easier or intuitive from the perspective of the Java application written to make use of + * ExifToolNew3. + *

+ * This class was written in order to make integration with ExifToolNew3 inside of a Java application seamless and + * performant with the goal being that the developer can treat ExifToolNew3 as if it were written in Java, garnering all + * of the benefits with none of the added headache of managing an external native process from Java. + *

+ * Phil Harvey's ExifToolNew3 is written in Perl and runs on all major platforms (including Windows) so no portability + * issues are introduced into your application by utilizing this class. + *

Usage

+ * Assuming ExifToolNew3 is installed on the host system correctly and either in the system path or pointed to by + * {@link #exifCmd}, using this class to communicate with ExifToolNew3 is as simple as creating an instance ( + * ExifToolNew3 tool = new ExifToolNew3()) and then making calls to + * {@link #getImageMeta3(File, ReadOptions, Tag...)} or {@link #getImageMeta4(File, ReadOptions, Format, Tag...)} with a + * list of {@link Tag}s you want to pull values for from the given image. + *

+ * In this default mode methods will automatically start an external ExifToolNew3 process to handle the request. After + * ExifToolNew3 has parsed the tag values from the file, the external process exits and this class parses the result + * before returning it to the caller. + *

+ * Results from calls to getImageMeta are returned in a {@link Map} with the {@link Tag} values as the keys + * and {@link String} values for every tag that had a value in the image file as the values. {@link Tag}s with no value + * found in the image are omitted from the result map. + *

+ * While each {@link Tag} provides a hint at which format the resulting value for that tag is returned as from + * ExifToolNew3 (see {@link Tag#getType()}), that only applies to values returned with an output format of + * {@link Format#NUMERIC} and it is ultimately up to the caller to decide how best to parse or convert the returned + * values. + *

+ * The {@link Tag} Enum provides the {@link Tag#parseValue(String)} convenience method for parsing given + * String values according to the Tag hint automatically for you if that is what you plan on doing, + * otherwise feel free to handle the return values anyway you want. + *

ExifToolNew3 -stay_open Support

+ * ExifToolNew3 8.36 added a new persistent-process feature that allows ExifToolNew3 to stay running in a daemon mode and + * continue accepting commands via a file or stdin. + *

+ * This new mode is controlled via the -stay_open True/False command line argument and in a busy system + * that is making thousands of calls to ExifToolNew3, can offer speed improvements of up to 60x (yes, + * really that much). + *

+ * This feature was added to ExifToolNew3 shortly after user Christian + * Etter discovered the overhead for starting up a new Perl interpreter each time ExifToolNew3 is loaded accounts + * for roughly 98.4% of + * the total runtime. + *

+ * Support for using ExifToolNew3 in daemon mode is enabled by passing {@link Feature#STAY_OPEN} to the constructor of + * the class when creating an instance of this class and then simply using the class as you normally would. This class + * will manage a single ExifToolNew3 process running in daemon mode in the background to service all future calls to the + * class. + *

+ * Because this feature requires ExifToolNew3 8.36 or later, this class will actually verify support for the feature in + * the version of ExifToolNew3 pointed at by {@link #exifCmd} before successfully instantiating the class and will + * notify you via an {@link UnsupportedFeatureException} if the native ExifToolNew3 doesn't support the requested + * feature. + *

+ * In the event of an {@link UnsupportedFeatureException}, the caller can either upgrade the native ExifToolNew3 upgrade + * to the version required or simply avoid using that feature to work around the exception. + *

Automatic Resource Cleanup

+ * When {@link Feature#STAY_OPEN} mode is used, there is the potential for leaking both host OS processes (native + * 'exiftool' processes) as well as the read/write streams used to communicate with it unless {@link #close()} is called + * to clean them up when done. Fortunately, this class provides an automatic cleanup mechanism that + * runs, by default, after 10mins of inactivity to clean up those stray resources. + *

+ * The inactivity period can be controlled by modifying the {@link #processCleanupDelay} system variable. A value of + * 0 or less disabled the automatic cleanup process and requires you to cleanup ExifToolNew3 instances on + * your own by calling {@link #close()} manually. + *

+ * Any class activity by way of calls to getImageMeta will always reset the inactivity timer, so in a busy + * system the cleanup thread could potentially never run, leaving the original host ExifToolNew3 process running forever + * (which is fine). + *

+ * This design was chosen to help make using the class and not introducing memory leaks and bugs into your code easier + * as well as making very inactive instances of this class light weight while not in-use by cleaning up after + * themselves. + *

+ * The only overhead incurred when opening the process back up is a 250-500ms lag while launching the VM interpreter + * again on the first call (depending on host machine speed and load). + *

Reusing a "closed" ExifToolNew3 Instance

+ * If you or the cleanup thread have called {@link #close()} on an instance of this class, cleaning up the host process + * and read/write streams, the instance of this class can still be safely used. Any followup calls to + * getImageMeta will simply re-instantiate all the required resources necessary to service the call + * (honoring any {@link Feature}s set). + *

+ * This can be handy behavior to be aware of when writing scheduled processing jobs that may wake up every hour and + * process thousands of pictures then go back to sleep. In order for the process to execute as fast as possible, you + * would want to use ExifToolNew3 in daemon mode (pass {@link Feature#STAY_OPEN} to the constructor of this class) and + * when done, instead of {@link #close()}-ing the instance of this class and throwing it out, you can keep the reference + * around and re-use it again when the job executes again an hour later. + *

Performance

+ * Extra care is taken to ensure minimal object creation or unnecessary CPU overhead while communicating with the + * external process. + *

+ * {@link Pattern}s used to split the responses from the process are explicitly compiled and reused, string + * concatenation is minimized, Tag name lookup is done via a static final {@link Map} shared by all + * instances and so on. + *

+ * Additionally, extra care is taken to utilize the most optimal code paths when initiating and using the external + * process, for example, the {@link ProcessBuilder#command(List)} method is used to avoid the copying of array elements + * when {@link ProcessBuilder#command(String...)} is used and avoiding the (hidden) use of {@link StringTokenizer} when + * {@link Runtime#exec(String)} is called. + *

+ * All of this effort was done to ensure that imgscalr and its supporting classes continue to provide best-of-breed + * performance and memory utilization in long running/high performance environments (e.g. web applications). + *

Thread Safety

+ * Instances of this class are not Thread-safe. Both the instance of this class and external + * ExifToolNew3 process maintain state specific to the current operation. Use of instances of this class need to be + * synchronized using an external mechanism or in a highly threaded environment (e.g. web application), instances of + * this class can be used along with {@link ThreadLocal}s to ensure Thread-safe, highly parallel use. + *

Why ExifToolNew3?

+ * ExifToolNew3 is written in Perl and requires an external + * process call from Java to make use of. + *

+ * While this would normally preclude a piece of software from inclusion into the imgscalr library (more complex + * integration), there is no other image metadata piece of software available as robust, complete and well-tested as + * ExifToolNew3. In addition, ExifToolNew3 already runs on all major platforms (including Windows), so there was not a + * lack of portability introduced by providing an integration for it. + *

+ * Allowing it to be used from Java is a boon to any Java project that needs the ability to read/write image-metadata + * from almost any image or video file format. + *

Alternatives

+ * If integration with an external Perl process is something your app cannot do and you still need image + * metadata-extraction capability, Drew Noakes has written the 2nd most robust image metadata library I have come + * across: Metadata Extractor that you might want to look + * at. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +public class ExifToolNew3 implements RawExifTool { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(ExifToolNew3.class); + + public static final String ENV_EXIF_TOOL_PATH = "exiftool.path"; + private static final String ENV_EXIF_TOOL_PROCESSCLEANUPDELAY = "exiftool.processCleanupDelay"; + private static final long DEFAULT_PROCESS_CLEANUP_DELAY = 0; + + /** + * Name used to identify the (optional) cleanup {@link Thread}. + *

+ * This is only provided to make debugging and profiling easier for implementers making use of this class such that + * the resources this class creates and uses (i.e. Threads) are readily identifiable in a running VM. + *

+ * Default value is "ExifToolNew3 Cleanup Thread". + */ + static final String CLEANUP_THREAD_NAME = "ExifToolNew3 Cleanup Thread"; + + /** + * Compiled {@link Pattern} of ": " used to split compact output from ExifToolNew3 evenly into name/value pairs. + */ + static final Pattern TAG_VALUE_PATTERN = Pattern.compile("\\s*:\\s*"); + static final String STREAM_CLOSED_MESSAGE = "Stream closed"; + + /** + * The absolute path to the ExifToolNew3 executable on the host system running this class as defined by the " + * exiftool.path" system property. + *

+ * If ExifToolNew3 is on your system path and running the command "exiftool" successfully executes it, leaving this + * value unchanged will work fine on any platform. If the ExifToolNew3 executable is named something else or not in + * the system path, then this property will need to be set to point at it before using this class. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.path=/path/to/exiftool + * or by calling {@link System#setProperty(String, String)} before this class is loaded. + *

+ * On Windows be sure to double-escape the path to the tool, for example: + * -Dexiftool.path=C:\\Tools\\exiftool.exe + * + *

+ * Default value is "exiftool". + *

Relative Paths

+ * Relative path values (e.g. "bin/tools/exiftool") are executed with relation to the base directory the VM process + * was started in. Essentially the directory that new File(".").getAbsolutePath() points at during + * runtime. + */ + private final String exifCmd; + + /** + * Interval (in milliseconds) of inactivity before the cleanup thread wakes up and cleans up the daemon ExifToolNew3 + * process and the read/write streams used to communicate with it when the {@link Feature#STAY_OPEN} feature is + * used. + *

+ * Ever time a call to getImageMeta is processed, the timer keeping track of cleanup is reset; more + * specifically, this class has to experience no activity for this duration of time before the cleanup process is + * fired up and cleans up the host OS process and the stream resources. + *

+ * Any subsequent calls to getImageMeta after a cleanup simply re-initializes the resources. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.processCleanupDelay=600000 + * or by calling {@link System#setProperty(String, String)} before this class is loaded. + *

+ * Setting this value to 0 disables the automatic cleanup thread completely and the caller will need to manually + * cleanup the external ExifToolNew3 process and read/write streams by calling {@link #close()}. + *

+ * Default value is 600,000 (10 minutes). + */ + private final long processCleanupDelay; + + private final Map featureSupportedMap = new HashMap(); + private final Set featureSet = EnumSet.noneOf(Feature.class); + private final ReentrantLock lock = new ReentrantLock(); + private final VersionNumber exifVersion; + private final Timer cleanupTimer; + private TimerTask currentCleanupTask = null; + private AtomicBoolean shuttingDown = new AtomicBoolean(false); + private volatile ExifProcess process; + private final Charset charset; + /** + * Limits the amount of time (in mills) an exif operation can take. Setting value to greater than 0 to enable. + */ + private final int timeoutWhenKeepAlive; + private static final int DEFAULT_TIMEOUT_WHEN_KEEP_ALIVE = 0; + + public ExifToolNew3() { + this((Feature[]) null); + } + + /** + * In this constructor, exifToolPath and processCleanupDelay are gotten from system properties exiftool.path and + * exiftool.processCleanupDelay. processCleanupDelay is optional. If not found, the default is used. + */ + public ExifToolNew3(Feature... features) { + this(DEFAULT_TIMEOUT_WHEN_KEEP_ALIVE, features); + } + + public ExifToolNew3(int timeoutWhenKeepAliveInMillis, Feature... features) { + this(System.getProperty(ENV_EXIF_TOOL_PATH, "exiftool"), Long.getLong(ENV_EXIF_TOOL_PROCESSCLEANUPDELAY, + DEFAULT_PROCESS_CLEANUP_DELAY), timeoutWhenKeepAliveInMillis, features); + } + + public ExifToolNew3(String exifToolPath) { + this(exifToolPath, DEFAULT_PROCESS_CLEANUP_DELAY, DEFAULT_TIMEOUT_WHEN_KEEP_ALIVE, (Feature[]) null); + } + + public ExifToolNew3(String exifToolPath, Feature... features) { + this(exifToolPath, DEFAULT_PROCESS_CLEANUP_DELAY, DEFAULT_TIMEOUT_WHEN_KEEP_ALIVE, features); + } + + public ExifToolNew3(String exifCmd, long processCleanupDelay, int timeoutWhenKeepAliveInMillis, Feature... features) { + this.exifCmd = exifCmd; + this.processCleanupDelay = processCleanupDelay; + this.exifVersion = ExifProcess.readVersion(exifCmd); + this.timeoutWhenKeepAlive = timeoutWhenKeepAliveInMillis; + if (features != null && features.length > 0) { + for (Feature feature : features) { + if (!feature.isSupported(exifVersion)) { + throw new UnsupportedFeatureException(feature); + } + this.featureSet.add(feature); + this.featureSupportedMap.put(feature, true); + } + } + + /* + * Now that initialization is done, init the cleanup timer if we are using STAY_OPEN and the delay time set is + * non-zero. + */ + if (isFeatureEnabled(Feature.STAY_OPEN)) { + cleanupTimer = new Timer(CLEANUP_THREAD_NAME, true); + } else { + cleanupTimer = null; + } + charset = computeDefaultCharset(Arrays.asList(features)); + } + + /** + * Used to determine if the given {@link Feature} is supported by the underlying native install of ExifToolNew3 + * pointed at by {@link #exifCmd}. + *

+ * If support for the given feature has not been checked for yet, this method will automatically call out to + * ExifToolNew3 and ensure the requested feature is supported in the current local install. + *

+ * The external call to ExifToolNew3 to confirm feature support is only ever done once per JVM session and stored in + * a static final {@link Map} that all instances of this class share. + * + * @param feature + * The feature to check support for in the underlying ExifToolNew3 install. + * + * @return true if support for the given {@link Feature} was confirmed to work with the currently + * installed ExifToolNew3 or false if it is not supported. + * + * @throws IllegalArgumentException + * if feature is null. + * @throws RuntimeException + * if any exception occurs while attempting to start the external ExifToolNew3 process to verify feature + * support. + */ + @Override + public boolean isFeatureSupported(Feature feature) throws RuntimeException { + if (feature == null) { + throw new IllegalArgumentException("feature cannot be null"); + } + + Boolean supported = featureSupportedMap.get(feature); + + /* + * If there is no Boolean flag for the feature, support for it hasn't been checked yet with the native + * ExifToolNew3 install, so we need to do that. + */ + if (supported == null) { + LOG.debug("Support for feature %s has not been checked yet, checking..."); + supported = feature.isSupported(exifVersion); + featureSupportedMap.put(feature, supported); + } + + return supported; + } + + /** + * Used to startup the external ExifToolNew3 process and open the read/write streams used to communicate with it + * when {@link Feature#STAY_OPEN} is enabled. This method has no effect if the stay open feature is not enabled. + */ + // @Override + // public void startup() { + // if (featureSet.contains(Feature.STAY_OPEN)) { + // shuttingDown.set(false); + // ensureProcessRunning(); + // } + // } + + private void ensureProcessRunning() { + if (process == null || process.isClosed()) { + synchronized (this) { + if (process == null || process.isClosed()) { + LOG.debug("Starting daemon ExifToolNew3 process and creating read/write streams (this only happens once)..."); + process = ExifProcess.startup(exifCmd, charset); + } + } + } + if (processCleanupDelay > 0) { + synchronized (this) { + if (currentCleanupTask != null) { + currentCleanupTask.cancel(); + currentCleanupTask = null; + } + currentCleanupTask = new TimerTask() { + @Override + public void run() { + LOG.info("Auto cleanup task running..."); + process.close(); + } + }; + cleanupTimer.schedule(currentCleanupTask, processCleanupDelay); + } + } + } + + /** + * This is same as {@link #close()}, added for consistency with {@link #startup()} + */ + @Override + public void shutdown() { + close(); + } + + /** + * Used to shutdown the external ExifToolNew3 process and close the read/write streams used to communicate with it + * when {@link Feature#STAY_OPEN} is enabled. + *

+ * NOTE: Calling this method does not preclude this instance of {@link ExifToolNew3} from being + * re-used, it merely disposes of the native and internal resources until the next call to getImageMeta + * causes them to be re-instantiated. + *

+ * The cleanup thread will automatically call this after an interval of inactivity defined by + * {@link #processCleanupDelay}. + *

+ * Calling this method on an instance of this class without {@link Feature#STAY_OPEN} support enabled has no effect. + */ + @Override + public synchronized void close() { + if (cleanupTimer != null) { + cleanupTimer.cancel(); + } + shuttingDown.set(true); + if (process != null) { + process.close(); + } + if (currentCleanupTask != null) { + currentCleanupTask.cancel(); + currentCleanupTask = null; + } + } + + @Override + public boolean isStayOpen() { + return featureSet.contains(Feature.STAY_OPEN); + } + + /** + * For {@link ExifToolNew3} instances with {@link Feature#STAY_OPEN} support enabled, this method is used to + * determine if there is currently a running ExifToolNew3 process associated with this class. + *

+ * Any dependent processes and streams can be shutdown using {@link #close()} and this class will automatically + * re-create them on the next call to getImageMeta if necessary. + * + * @return true if there is an external ExifToolNew3 process in daemon mode associated with this class + * utilizing the {@link Feature#STAY_OPEN} feature, otherwise returns false. + */ + @Override + public boolean isRunning() { + return process != null && !process.isClosed(); + } + + /** + * Used to determine if the given {@link Feature} has been enabled for this particular instance of + * {@link ExifToolNew3}. + *

+ * This method is different from {@link #isFeatureSupported(Feature)}, which checks if the given feature is + * supported by the underlying ExifToolNew3 install where as this method tells the caller if the given feature has + * been enabled for use in this particular instance. + * + * @param feature + * The feature to check if it has been enabled for us or not on this instance. + * + * @return true if the given {@link Feature} is currently enabled on this instance of + * {@link ExifToolNew3}, otherwise returns false. + * + * @throws IllegalArgumentException + * if feature is null. + */ + @Override + public boolean isFeatureEnabled(Feature feature) throws IllegalArgumentException { + if (feature == null) { + throw new IllegalArgumentException("feature cannot be null"); + } + return featureSet.contains(feature); + } + + @Override + public Map getImageMeta(File file, ReadOptions readOptions, String... tags) throws IOException { + // Validate input and create Arg Array + final boolean stayOpen = featureSet.contains(Feature.STAY_OPEN); + if (tags == null) { + tags = new String[0]; + } + List args = new ArrayList(tags.length + 4); + if (readOptions == null) { + throw new IllegalArgumentException("format cannot be null"); + } else if (readOptions.numericOutput) { + args.add("-n"); // numeric output + } + if (readOptions.showDuplicates) { + // args.add("-a"); + args.add("-duplicates"); // allow duplicates to be shown + } + // -S or -veryShort + args.add("-veryShort"); // compact output + for (String tag : tags) { + args.add("-" + tag); + } + if (file == null) { + throw new IllegalArgumentException("image cannot be null and must be a valid stream of image data."); + } + if (!file.canRead()) { + throw new SecurityException( + "Unable to read the given image [" + + file.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to read it."); + } + String absoluteName = getAbsoluteFileName(file); + String fileName = absoluteName; + File tempFileName = null; + if (absoluteName == null) { + tempFileName = getTemporaryCopiedFileName(file); + fileName = tempFileName.getAbsolutePath(); + LOG.info("Exiftool will work with temporary file " + fileName + " for original file [" + absoluteName + + "]."); + } + Map resultMap; + try { + args.add(fileName); + + // start process + long startTime = System.currentTimeMillis(); + LOG.debug(String.format("Querying %d tags from image: %s", tags.length, file.getAbsolutePath())); + LOG.info("call stayOpen=" + stayOpen + " exiftool " + Joiner.on(" ").join(args)); + /* + * Using ExifToolNew3 in daemon mode (-stay_open True) executes different code paths below. So establish the + * flag for this once and it is reused a multitude of times later in this method to figure out where to + * branch to. + */ + if (stayOpen) { + LOG.debug("Using ExifToolNew3 in daemon mode (-stay_open True)..."); + resultMap = processStayOpen(args); + } else { + LOG.debug("Using ExifToolNew3 in non-daemon mode (-stay_open False)..."); + resultMap = ExifToolService.toMap(execute(args)); + } + + // Print out how long the call to external ExifToolNew3 process took. + if (LOG.isDebugEnabled()) { + LOG.debug(String.format("Image Meta Processed in %d ms [queried %d tags and found %d values]", + (System.currentTimeMillis() - startTime), tags.length, resultMap.size())); + } + } finally { + if (tempFileName != null) { + FileUtils.forceDelete(tempFileName); + } + } + return resultMap; + } + + private File getTemporaryCopiedFileName(File file) { + File dest = new File(file.getParentFile(), "temp"); + File temp = findFirstUniqueFile(dest); + copyFromAsHardLink(file,temp, false); + return temp; + } + + void copyFromAsHardLink(File src, File dest, Boolean overwriteIfAlreadyExists) { + try { + if (overwriteIfAlreadyExists) { + if (dest.exists()) { + FileUtils.forceDelete(dest); + } + FileUtils.forceMkdir(dest.getParentFile()); + Files.createLink(dest.toPath(), src.toPath()); + } else { + if (dest.exists()) { + throw new RuntimeException("Destination file " + this + " already exists."); + } else { + FileUtils.forceMkdir(dest.getParentFile()); + Files.createLink(dest.toPath(), src.toPath()); + } + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private File findFirstUniqueFile(File dest) { + if(!dest.exists()){ + return dest; + } + File parent = dest.getParentFile(); + String full = dest.getAbsolutePath(); + String extension = FilenameUtils.getExtension(full); + String current = FilenameUtils.getBaseName(full); + int counter = 1; + int MAX_COUNTER = 100000; + do { + File file = new File(parent, current + "-" + counter + "." + extension); + if (!file.exists()) { + return file; + } + counter++; + } while (counter < MAX_COUNTER); + throw new RuntimeException("Couldn't find a unique name similar with ["+full+"] till "+MAX_COUNTER); + } + + public String getAbsoluteFileName(File file) { + // if (!CharMatcher.ASCII.matchesAllOf(file.getAbsolutePath()) && featureSet.contains(Feature.WINDOWS)) + // return getMSDOSName(file); + // else + // return file.getAbsolutePath(); + if (CharMatcher.ascii().matchesAllOf(file.getAbsolutePath())) { + return file.getAbsolutePath(); + } + return null; + } + + /** + * There is a bug that prevents exiftool to read unicode file names. We can get the windows filename if necessary + * with getMSDOSName + * + * @link(http://perlmaven.com/unicode-filename-support-suggested-solution) + * @link(http://stackoverflow.com/questions/18893284/how-to-get-short-filenames-in-windows-using-java) + */ + public static String getMSDOSName(File file) { + try { + String path = getAbsolutePath(file); + String path2 = file.getAbsolutePath(); + System.out.println(path2); + // String toExecute = "cmd.exe /c for %I in (\"" + path2 + "\") do @echo %~fsI"; + // ProcessBuilder pb = new ProcessBuilder("cmd","/c","for","%I","in","(" + path2 + ")","do","@echo","%~sI"); + path2 = new File( + "d:\\aaaaaaaaaaaaaaaaaaaaaaaaaaaa\\bbbbbbbbbbbbbbbbbb\\2013-12-22--12-10-42------Bulevardul-Petrochimiștilor.jpg") + .getAbsolutePath(); + path2 = new File( + "d:\\personal\\photos-tofix\\2013-proposed1-bad\\2013-12-22--12-10-42------Bulevardul-Petrochimiștilor.jpg") + .getAbsolutePath(); + + System.out.println(path2); + ProcessBuilder pb = new ProcessBuilder("cmd", "/c", "for", "%I", "in", "(\"" + path2 + "\")", "do", + "@echo", "%~fsI"); + // ProcessBuilder pb = new ProcessBuilder("cmd","/c","chcp 65001 & dir",path2); + // ProcessBuilder pb = new ProcessBuilder("cmd","/c","ls",path2); + Process process = pb.start(); + // Process process = Runtime.getRuntime().exec(execLine); + // Process process = Runtime.getRuntime().exec(new String[]{"cmd","/c","for","%I","in","(\"" + path2 + + // "\")","do","@echo","%~fsI"}); + process.waitFor(); + byte[] data = new byte[65536]; + // InputStreamReader isr = new InputStreamReader(process.getInputStream(), "UTF-8"); + // String charset = Charset.defaultCharset().name(); + String charset = "UTF-8"; + String lines = IOUtils.toString(process.getInputStream(), charset); + // int size = process.getInputStream().read(data); + // String path3 = path; + // if (size > 0) + // path3 = new String(data, 0, size).replaceAll("\\r\\n", ""); + String path3 = lines; + System.out.println(pb.command()); + System.out.println(path3); + byte[] data2 = new byte[65536]; + int size2 = process.getErrorStream().read(data2); + if (size2 > 0) { + String error = new String(data2, 0, size2); + System.out.println(error); + throw new RuntimeException("Error was thrown " + error); + } + return path3; + } catch (IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static String getAbsolutePath(File file) throws IOException { + String path = file.getAbsolutePath(); + if (file.exists() == false) + file = new File(path); + path = file.getCanonicalPath(); + if (file.isDirectory() && (path.endsWith(File.separator) == false)) + path += File.separator; + return path; + } + + @Override + public void addImageMetadata(File image, Map values) throws IOException { + // public void addImageMetadata(File image, Map values) + // throws IOException { + + if (image == null) { + throw new IllegalArgumentException("image cannot be null and must be a valid stream of image data."); + } + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values cannot be null and must contain 1 or more tag to value mappings"); + } + + if (!image.canWrite()) { + throw new SecurityException( + "Unable to write the given image [" + + image.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to write to it."); + } + + LOG.info("Adding Tags {} to {}", values, image.getAbsolutePath()); + + // start process + long startTime = System.currentTimeMillis(); + + execute(null, image, values); + + // Print out how long the call to external ExifToolNew3 process took. + if (LOG.isDebugEnabled()) { + LOG.debug(String.format("Image Meta Processed in %d ms [added %d tags]", + (System.currentTimeMillis() - startTime), values.size())); + } + } + + private void execute(WriteOptions options, File image, Map values) throws IOException { + final boolean stayOpen = featureSet.contains(Feature.STAY_OPEN); + if (stayOpen) { + LOG.debug("Using ExifToolNew3 in daemon mode (-stay_open True)..."); + processStayOpen(createCommandList(image.getAbsolutePath(), values, stayOpen)); + } else { + LOG.debug("Using ExifToolNew3 in non-daemon mode (-stay_open False)..."); + ExifProcess + .executeToResults(exifCmd, createCommandList(image.getAbsolutePath(), values, stayOpen), charset); + } + } + + private List createCommandList(String filename, Map values, boolean stayOpen) { + List args = new ArrayList(64); + for (Map.Entry entry : values.entrySet()) { + MetadataTag tag = (MetadataTag) entry.getKey(); + Object value = entry.getValue(); + args.addAll(toRawData(tag, value)); + } + args.add(filename); + return args; + + } + + private List toRawData(MetadataTag tag, Object value) { + if (tag.getType().equals(String[].class)) { + List result = new LinkedList(); + String[] array = (String[]) value; + for (String value2 : array) { + String raw = getRawExif(tag, value2); + result.add(raw); + } + return result; + } else { + String raw = getRawExif(tag, value); + return Lists.newArrayList(raw); + } + } + + private String getRawExif(MetadataTag tag, Object value) { + StringBuilder arg = new StringBuilder(); + arg.append("-").append(tag.getKey()); + if (value instanceof Number) { + arg.append("#"); + } + arg.append("="); + if (value != null) { + // if (value instanceof String && !stayOpen) { + // arg.append("\"").append(value.toString()).append("\""); + // } else { + arg.append(tag.toExif(value)); + // } + } + String raw = arg.toString(); + return raw; + } + + /** + * Will attempt 3 times to use the running exif process, and if unable to complete successfully will throw + * IOException + */ + private Map processStayOpen(List args) throws IOException { + int attempts = 0; + while (attempts < 3 && !shuttingDown.get()) { + attempts++; + // make sure process is started + ensureProcessRunning(); + TimerTask attemptTimer = null; + try { + if (timeoutWhenKeepAlive > 0) { + attemptTimer = new TimerTask() { + @Override + public void run() { + LOG.warn("Process ran too long closing, max " + timeoutWhenKeepAlive + " mills"); + process.close(); + } + }; + cleanupTimer.schedule(attemptTimer, timeoutWhenKeepAlive); + } + LOG.debug("Streaming arguments to ExifToolNew3 process..."); + return ExifToolService.toMap(process.sendArgs(args)); + } catch (IOException ex) { + if (STREAM_CLOSED_MESSAGE.equals(ex.getMessage()) && !shuttingDown.get()) { + // only catch "Stream Closed" error (happens when process + // has died) + LOG.warn(String.format("Caught IOException(\"%s\"), will restart daemon", STREAM_CLOSED_MESSAGE)); + process.close(); + } else { + throw ex; + } + } finally { + if (attemptTimer != null) + attemptTimer.cancel(); + } + } + if (shuttingDown.get()) { + throw new IOException("Shutting Down"); + } + throw new IOException("Ran out of attempts"); + } + + /** + * Helper method used to ensure a message is loggable before it is logged and then pre-pend a universal prefix to + * all LOG messages generated by this library to make the LOG entries easy to parse visually or programmatically. + *

+ * If a message cannot be logged (logging is disabled) then this method returns immediately. + *

+ * NOTE: Because Java will auto-box primitive arguments into Objects when building out the + * params array, care should be taken not to call this method with primitive values unless + * {@link #DEBUG} is true; otherwise the VM will be spending time performing unnecessary auto-boxing + * calculations. + * + * @param message + * The LOG message in format string + * syntax that will be logged. + * @param params + * The parameters that will be swapped into all the place holders in the original messages before being + * logged. + * + * @see #LOG_PREFIX + */ + protected static void log(String message, Object... params) { + LOG.debug(message, params); + } + + @Override + public void rebuildMetadata(File file) throws IOException { + throw new RuntimeException("Not implemented."); + } + + @Override + public void rebuildMetadata(WriteOptions options, File file) throws IOException { + throw new RuntimeException("Not implemented."); + } + + @Override + public void writeMetadata(WriteOptions options, File image, Map values) throws IOException { + addImageMetadata(image, values); + // throw new RuntimeException("Not implemented."); + } + + @Override + protected void finalize() throws Throwable { + LOG.debug("Shutdown on finalize ..."); + shutdown(); + super.finalize(); + } + + public static Charset computeDefaultCharset(Collection features) { + if (features.contains(Feature.WINDOWS)) + return Charset.forName("windows-1252"); + return Charset.defaultCharset(); + } + + @Override + public List execute(List args) { + try { + return ExifProcess.executeToResults(exifCmd, args, charset); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/ExifToolOld1.java b/src/main/java/com/thebuzzmedia/exiftool/ExifToolOld1.java new file mode 100644 index 00000000..844e3ad3 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/ExifToolOld1.java @@ -0,0 +1,1449 @@ +/** + * Copyright 2011 The Buzz Media, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.thebuzzmedia.exiftool; + +import java.io.*; +import java.util.*; +import java.util.Map.Entry; +import java.util.regex.Pattern; + +/** + * Class used to provide a Java-like interface to Phil Harvey's excellent, + * Perl-based ExifTool. + *

+ * There are a number of other basic Java wrappers to ExifTool available online, + * but most of them only abstract out the actual Java-external-process execution + * logic and do no additional work to make integration with the external + * ExifTool any easier or intuitive from the perspective of the Java application + * written to make use of ExifTool. + *

+ * This class was written in order to make integration with ExifTool inside of a + * Java application seamless and performant with the goal being that the + * developer can treat ExifTool as if it were written in Java, garnering all of + * the benefits with none of the added headache of managing an external native + * process from Java. + *

+ * Phil Harvey's ExifTool is written in Perl and runs on all major platforms + * (including Windows) so no portability issues are introduced into your + * application by utilizing this class. + *

Usage

+ * Assuming ExifTool is installed on the host system correctly and either in the + * system path or pointed to by {@link #EXIF_TOOL_PATH}, using this class to + * communicate with ExifTool is as simple as creating an instance ( + * ExifTool tool = new ExifTool()) and then making calls to + * {@link #getImageMeta(File, Tag...)} or + * {@link #getImageMeta(File, Format, Tag...)} with a list of {@link Tag}s you + * want to pull values for from the given image. + *

+ * In this default mode, calls to getImageMeta will automatically + * start an external ExifTool process to handle the request. After ExifTool has + * parsed the tag values from the file, the external process exits and this + * class parses the result before returning it to the caller. + *

+ * Results from calls to getImageMeta are returned in a {@link Map} + * with the {@link Tag} values as the keys and {@link String} values for every + * tag that had a value in the image file as the values. {@link Tag}s with no + * value found in the image are omitted from the result map. + *

+ * While each {@link Tag} provides a hint at which format the resulting value + * for that tag is returned as from ExifTool (see {@link Tag#getType()}), that + * only applies to values returned with an output format of + * {@link Format#NUMERIC} and it is ultimately up to the caller to decide how + * best to parse or convert the returned values. + *

+ * The {@link Tag} Enum provides the {@link Tag#parseValue(Tag, String)} + * convenience method for parsing given String values according to + * the Tag hint automatically for you if that is what you plan on doing, + * otherwise feel free to handle the return values anyway you want. + *

ExifTool -stay_open Support

+ * ExifTool 8.36 added a new persistent-process feature that allows ExifTool to stay + * running in a daemon mode and continue accepting commands via a file or stdin. + *

+ * This new mode is controlled via the -stay_open True/False + * command line argument and in a busy system that is making thousands of calls + * to ExifTool, can offer speed improvements of up to 60x (yes, + * really that much). + *

+ * This feature was added to ExifTool shortly after user Christian Etter discovered + * the overhead for starting up a new Perl interpreter each time ExifTool is + * loaded accounts for roughly 98.4% of the total runtime. + *

+ * Support for using ExifTool in daemon mode is enabled by passing + * {@link Feature#STAY_OPEN} to the constructor of the class when creating an + * instance of this class and then simply using the class as you normally would. + * This class will manage a single ExifTool process running in daemon mode in + * the background to service all future calls to the class. + *

+ * Because this feature requires ExifTool 8.36 or later, this class will + * actually verify support for the feature in the version of ExifTool pointed at + * by {@link #EXIF_TOOL_PATH} before successfully instantiating the class and + * will notify you via an {@link UnsupportedFeatureException} if the native + * ExifTool doesn't support the requested feature. + *

+ * In the event of an {@link UnsupportedFeatureException}, the caller can either + * upgrade the native ExifTool upgrade to the version required or simply avoid + * using that feature to work around the exception. + *

Automatic Resource Cleanup

+ * When {@link Feature#STAY_OPEN} mode is used, there is the potential for + * leaking both host OS processes (native 'exiftool' processes) as well as the + * read/write streams used to communicate with it unless {@link #close()} is + * called to clean them up when done. Fortunately, this class + * provides an automatic cleanup mechanism that runs, by default, after 10mins + * of inactivity to clean up those stray resources. + *

+ * The inactivity period can be controlled by modifying the + * {@link #PROCESS_CLEANUP_DELAY} system variable. A value of 0 or + * less disabled the automatic cleanup process and requires you to cleanup + * ExifTool instances on your own by calling {@link #close()} manually. + *

+ * Any class activity by way of calls to getImageMeta will always + * reset the inactivity timer, so in a busy system the cleanup thread could + * potentially never run, leaving the original host ExifTool process running + * forever (which is fine). + *

+ * This design was chosen to help make using the class and not introducing + * memory leaks and bugs into your code easier as well as making very inactive + * instances of this class light weight while not in-use by cleaning up after + * themselves. + *

+ * The only overhead incurred when opening the process back up is a 250-500ms + * lag while launching the VM interpreter again on the first call (depending on + * host machine speed and load). + *

Reusing a "closed" ExifTool Instance

+ * If you or the cleanup thread have called {@link #close()} on an instance of + * this class, cleaning up the host process and read/write streams, the instance + * of this class can still be safely used. Any followup calls to + * getImageMeta will simply re-instantiate all the required + * resources necessary to service the call (honoring any {@link Feature}s set). + *

+ * This can be handy behavior to be aware of when writing scheduled processing + * jobs that may wake up every hour and process thousands of pictures then go + * back to sleep. In order for the process to execute as fast as possible, you + * would want to use ExifTool in daemon mode (pass {@link Feature#STAY_OPEN} to + * the constructor of this class) and when done, instead of {@link #close()}-ing + * the instance of this class and throwing it out, you can keep the reference + * around and re-use it again when the job executes again an hour later. + *

Performance

+ * Extra care is taken to ensure minimal object creation or unnecessary CPU + * overhead while communicating with the external process. + *

+ * {@link Pattern}s used to split the responses from the process are explicitly + * compiled and reused, string concatenation is minimized, Tag name lookup is + * done via a static final {@link Map} shared by all instances and + * so on. + *

+ * Additionally, extra care is taken to utilize the most optimal code paths when + * initiating and using the external process, for example, the + * {@link ProcessBuilder#command(List)} method is used to avoid the copying of + * array elements when {@link ProcessBuilder#command(String...)} is used and + * avoiding the (hidden) use of {@link StringTokenizer} when + * {@link Runtime#exec(String)} is called. + *

+ * All of this effort was done to ensure that imgscalr and its supporting + * classes continue to provide best-of-breed performance and memory utilization + * in long running/high performance environments (e.g. web applications). + *

Thread Safety

+ * Instances of this class are not Thread-safe. Both the + * instance of this class and external ExifTool process maintain state specific + * to the current operation. Use of instances of this class need to be + * synchronized using an external mechanism or in a highly threaded environment + * (e.g. web application), instances of this class can be used along with + * {@link ThreadLocal}s to ensure Thread-safe, highly parallel use. + *

Why ExifTool?

+ * ExifTool is + * written in Perl and requires an external process call from Java to make use + * of. + *

+ * While this would normally preclude a piece of software from inclusion into + * the imgscalr library (more complex integration), there is no other image + * metadata piece of software available as robust, complete and well-tested as + * ExifTool. In addition, ExifTool already runs on all major platforms + * (including Windows), so there was not a lack of portability introduced by + * providing an integration for it. + *

+ * Allowing it to be used from Java is a boon to any Java project that needs the + * ability to read/write image-metadata from almost any image or + * video file format. + *

Alternatives

+ * If integration with an external Perl process is something your app cannot do + * and you still need image metadata-extraction capability, Drew Noakes has + * written the 2nd most robust image metadata library I have come across: Metadata Extractor + * that you might want to look at. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +@Deprecated +public class ExifToolOld1 { + /** + * Flag used to indicate if debugging output has been enabled by setting the + * "exiftool.debug" system property to true. This + * value will be false if the " exiftool.debug" + * system property is undefined or set to false. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.debug=true + * or by calling {@link System#setProperty(String, String)} before + * this class is loaded. + *

+ * Default value is false. + */ + public static final Boolean DEBUG = Boolean.getBoolean("exiftool.debug"); + + /** + * Prefix to every log message this library logs. Using a well-defined + * prefix helps make it easier both visually and programmatically to scan + * log files for messages produced by this library. + *

+ * The value is "[exiftool] " (including the space). + */ + public static final String LOG_PREFIX = "[exiftool] "; + + /** + * The absolute path to the ExifTool executable on the host system running + * this class as defined by the "exiftool.path" system + * property. + *

+ * If ExifTool is on your system path and running the command "exiftool" + * successfully executes it, leaving this value unchanged will work fine on + * any platform. If the ExifTool executable is named something else or not + * in the system path, then this property will need to be set to point at it + * before using this class. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.path=/path/to/exiftool + * or by calling {@link System#setProperty(String, String)} before + * this class is loaded. + *

+ * On Windows be sure to double-escape the path to the tool, for example: + * + * -Dexiftool.path=C:\\Tools\\exiftool.exe + * + *

+ * Default value is "exiftool". + *

Relative Paths

+ * Relative path values (e.g. "bin/tools/exiftool") are executed with + * relation to the base directory the VM process was started in. Essentially + * the directory that new File(".").getAbsolutePath() points at + * during runtime. + */ + public static final String EXIF_TOOL_PATH = System.getProperty( + "exiftool.path", "exiftool"); + + /** + * Interval (in milliseconds) of inactivity before the cleanup thread wakes + * up and cleans up the daemon ExifTool process and the read/write streams + * used to communicate with it when the {@link Feature#STAY_OPEN} feature is + * used. + *

+ * Ever time a call to getImageMeta is processed, the timer + * keeping track of cleanup is reset; more specifically, this class has to + * experience no activity for this duration of time before the cleanup + * process is fired up and cleans up the host OS process and the stream + * resources. + *

+ * Any subsequent calls to getImageMeta after a cleanup simply + * re-initializes the resources. + *

+ * This system property can be set on startup with:
+ * + * -Dexiftool.processCleanupDelay=600000 + * or by calling {@link System#setProperty(String, String)} before + * this class is loaded. + *

+ * Setting this value to 0 disables the automatic cleanup thread completely + * and the caller will need to manually cleanup the external ExifTool + * process and read/write streams by calling {@link #close()}. + *

+ * Default value is 600,000 (10 minutes). + */ + public static final long PROCESS_CLEANUP_DELAY = Long.getLong( + "exiftool.processCleanupDelay", 600000); + + /** + * Name used to identify the (optional) cleanup {@link Thread}. + *

+ * This is only provided to make debugging and profiling easier for + * implementors making use of this class such that the resources this class + * creates and uses (i.e. Threads) are readily identifiable in a running VM. + *

+ * Default value is "ExifTool Cleanup Thread". + */ + protected static final String CLEANUP_THREAD_NAME = "ExifTool Cleanup Thread"; + + /** + * Compiled {@link Pattern} of ": " used to split compact output from + * ExifTool evenly into name/value pairs. + */ + protected static final Pattern TAG_VALUE_PATTERN = Pattern.compile(": "); + + /** + * Map shared across all instances of this class that maintains the state of + * {@link Feature}s and if they are supported or not (supported=true, + * unsupported=false) by the underlying native ExifTool process being used + * in conjunction with this class. + *

+ * If a {@link Feature} is missing from the map (has no true or + * false flag associated with it, but null + * instead) then that means that feature has not been checked for support + * yet and this class will know to call + * {@link #checkFeatureSupport(Feature...)} on it to determine its supported + * state. + *

+ * For efficiency reasons, individual {@link Feature}s are checked for + * support one time during each run of the VM and never again during the + * session of that running VM. + */ + protected static final Map FEATURE_SUPPORT_MAP = new HashMap(); + + /** + * Static list of args used to execute ExifTool using the '-ver' flag in + * order to get it to print out its version number. Used by the + * {@link #checkFeatureSupport(Feature...)} method to check all the required + * feature versions. + *

+ * Defined here as a static final list because it is used every + * time and never changes. + */ + private static final List VERIFY_FEATURE_ARGS = new ArrayList( + 2); + + static { + VERIFY_FEATURE_ARGS.add(EXIF_TOOL_PATH); + VERIFY_FEATURE_ARGS.add("-ver"); + } + + /** + * Used to determine if the given {@link Feature} is supported by the + * underlying native install of ExifTool pointed at by + * {@link #EXIF_TOOL_PATH}. + *

+ * If support for the given feature has not been checked for yet, this + * method will automatically call out to ExifTool and ensure the requested + * feature is supported in the current local install. + *

+ * The external call to ExifTool to confirm feature support is only ever + * done once per JVM session and stored in a static final + * {@link Map} that all instances of this class share. + * + * @param feature + * The feature to check support for in the underlying ExifTool + * install. + * + * @return true if support for the given {@link Feature} was + * confirmed to work with the currently installed ExifTool or + * false if it is not supported. + * + * @throws IllegalArgumentException + * if feature is null. + * @throws RuntimeException + * if any exception occurs while attempting to start the + * external ExifTool process to verify feature support. + */ + public static boolean isFeatureSupported(Feature feature) + throws IllegalArgumentException, RuntimeException { + if (feature == null) + throw new IllegalArgumentException("feature cannot be null"); + + Boolean supported = FEATURE_SUPPORT_MAP.get(feature); + + /* + * If there is no Boolean flag for the feature, support for it hasn't + * been checked yet with the native ExifTool install, so we need to do + * that. + */ + if (supported == null) { + log("\tSupport for feature %s has not been checked yet, checking..."); + checkFeatureSupport(feature); + + // Re-query for the supported state + supported = FEATURE_SUPPORT_MAP.get(feature); + } + + return supported; + } + + /** + * Helper method used to ensure a message is loggable before it is logged + * and then pre-pend a universal prefix to all log messages generated by + * this library to make the log entries easy to parse visually or + * programmatically. + *

+ * If a message cannot be logged (logging is disabled) then this method + * returns immediately. + *

+ * NOTE: Because Java will auto-box primitive arguments + * into Objects when building out the params array, care should + * be taken not to call this method with primitive values unless + * {@link #DEBUG} is true; otherwise the VM will be spending + * time performing unnecessary auto-boxing calculations. + * + * @param message + * The log message in format string syntax that will be logged. + * @param params + * The parameters that will be swapped into all the place holders + * in the original messages before being logged. + * + * @see #LOG_PREFIX + */ + protected static void log(String message, Object... params) { + if (DEBUG) + System.out.printf(LOG_PREFIX + message + '\n', params); + } + + /** + * Used to verify the version of ExifTool installed is a high enough version + * to support the given features. + *

+ * This method runs the command "exiftool -ver" to get the + * version of the installed ExifTool and then compares that version to the + * least required version specified by the given features (see + * {@link Feature#getVersion()}). + * + * @param features + * The features whose required versions will be checked against + * the installed ExifTool for support. + * + * @throws RuntimeException + * if any exception occurs communicating with the external + * ExifTool process spun up in order to check its version. + */ + protected static void checkFeatureSupport(Feature... features) + throws RuntimeException { + // Ensure there is work to do. + if (features == null || features.length == 0) + return; + + log("\tChecking %d feature(s) for support in the external ExifTool install...", + features.length); + + for (int i = 0; i < features.length; i++) { + String ver = null; + Boolean supported; + Feature feature = features[i]; + + log("\t\tChecking feature %s for support, requires ExifTool version %s or higher...", + feature, feature.version); + + // Execute 'exiftool -ver' + IOStream streams = startExifToolProcess(VERIFY_FEATURE_ARGS); + + try { + // Read the single-line reply (version number) + ver = streams.reader.readLine(); + + // Close r/w streams to exited process. + streams.close(); + } catch (Exception e) { + /* + * no-op, while it is important to know that we COULD launch the + * ExifTool process (i.e. startExifToolProcess call worked) but + * couldn't communicate with it, the context with which this + * method is called is from the constructor of this class which + * would just wrap this exception and discard it anyway if it + * failed. + * + * the caller will realize there is something wrong with the + * ExifTool process communication as soon as they make their + * first call to getImageMeta in which case whatever was causing + * the exception here will popup there and then need to be + * corrected. + * + * This is an edge case that should only happen in really rare + * scenarios, so making this method easier to use is more + * important that robust IOException handling right here. + */ + } + + // Ensure the version found is >= the required version. + if (ver != null && ver.compareTo(feature.version) >= 0) { + supported = Boolean.TRUE; + log("\t\tFound ExifTool version %s, feature %s is SUPPORTED.", + ver, feature); + } else { + supported = Boolean.FALSE; + log("\t\tFound ExifTool version %s, feature %s is NOT SUPPORTED.", + ver, feature); + } + + // Update feature support map + FEATURE_SUPPORT_MAP.put(feature, supported); + } + } + + protected static IOStream startExifToolProcess(List args) + throws RuntimeException { + Process proc = null; + IOStream streams = null; + + log("\tAttempting to start external ExifTool process using args: %s", + args); + + try { + proc = new ProcessBuilder(args).start(); + log("\t\tSuccessful"); + } catch (Exception e) { + String message = "Unable to start external ExifTool process using the execution arguments: " + + args + + ". Ensure ExifTool is installed correctly and runs using the command path '" + + EXIF_TOOL_PATH + + "' as specified by the 'exiftool.path' system property."; + + log(message); + throw new RuntimeException(message, e); + } + + log("\tSetting up Read/Write streams to the external ExifTool process..."); + + // Setup read/write streams to the new process. + streams = new IOStream(new BufferedReader(new InputStreamReader( + proc.getInputStream())), new OutputStreamWriter( + proc.getOutputStream())); + + log("\t\tSuccessful, returning streams to caller."); + return streams; + } + + /** + * Simple class used to house the read/write streams used to communicate + * with an external ExifTool process as well as the logic used to safely + * close the streams when no longer needed. + *

+ * This class is just a convenient way to group and manage the read/write + * streams as opposed to making them dangling member variables off of + * ExifTool directly. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ + private static class IOStream { + BufferedReader reader; + OutputStreamWriter writer; + + public IOStream(BufferedReader reader, OutputStreamWriter writer) { + this.reader = reader; + this.writer = writer; + } + + public void close() { + try { + log("\tClosing Read stream..."); + reader.close(); + log("\t\tSuccessful"); + } catch (Exception e) { + // no-op, just try to close it. + } + + try { + log("\tClosing Write stream..."); + writer.close(); + log("\t\tSuccessful"); + } catch (Exception e) { + // no-op, just try to close it. + } + + // Null the stream references. + reader = null; + writer = null; + + log("\tRead/Write streams successfully closed."); + } + } + + /** + * Enum used to define the different kinds of features in the native + * ExifTool executable that this class can help you take advantage of. + *

+ * These flags are different from {@link Tag}s in that a "feature" is + * determined to be a special functionality of the underlying ExifTool + * executable that requires a different code-path in this class to take + * advantage of; for example, -stay_open True support. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ + public enum Feature { + /** + * Enum used to specify that you wish to launch the underlying ExifTool + * process with -stay_open True support turned on that this + * class can then take advantage of. + *

+ * Required ExifTool version is 8.36 or higher. + */ + STAY_OPEN("8.36"); + + /** + * Used to get the version of ExifTool required by this feature in order + * to work. + * + * @return the version of ExifTool required by this feature in order to + * work. + */ + public String getVersion() { + return version; + } + + private String version; + + private Feature(String version) { + this.version = version; + } + } + + /** + * Enum used to define the 2 different output formats that {@link Tag} + * values can be returned in: numeric or human-readable text. + *

+ * ExifTool, via the -n command line arg, is capable of + * returning most values in their raw numeric form (e.g. + * Aperture="2.8010323841") as well as a more human-readable/friendly format + * (e.g. Aperture="2.8"). + *

+ * While the {@link Tag}s defined on this class do provide a hint at the + * type of the result (see {@link Tag#getType()}), that hint only applies + * when the {@link Format#NUMERIC} form of the value is returned. + *

+ * If the caller finds the human-readable format easier to process, + * {@link Format#HUMAN_READABLE} can be specified when calling + * {@link ExifTool#getImageMeta(File, Format, Tag...)} and the returned + * {@link String} values processed manually by the caller. + *

+ * In order to see the types of values that are returned when + * {@link Format#HUMAN_READABLE} is used, you can check the comprehensive + * ExifTool Tag Guide. + *

+ * This makes sense with some values like Aperture that in + * {@link Format#NUMERIC} format end up returning as 14-decimal-place, high + * precision values that are near the intended value (e.g. + * "2.79999992203711" instead of just returning "2.8"). On the other hand, + * other values (like Orientation) are easier to parse when their numeric + * value (1-8) is returned instead of a much longer friendly name (e.g. + * "Mirror horizontal and rotate 270 CW"). + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ + public enum Format { + NUMERIC, HUMAN_READABLE; + } + + /** + * Enum used to pre-define a convenient list of tags that can be easily + * extracted from images using this class with an external install of + * ExifTool. + *

+ * Each tag defined also includes a type hint for the parsed value + * associated with it when the default {@link Format#NUMERIC} value format + * is used. + *

+ * All replies from ExifTool are parsed as {@link String}s and using the + * type hint from each {@link Tag} can easily be converted to the correct + * data format by using the provided {@link Tag#parseValue(Tag, String)} + * method. + *

+ * This class does not make an attempt at converting the value automatically + * in case the caller decides they would prefer tag values returned in + * {@link Format#HUMAN_READABLE} format and to avoid any compatibility + * issues with future versions of ExifTool if a tag's return value is + * changed. This approach to leaving returned tag values as strings until + * the caller decides they want to parse them is a safer and more robust + * approach. + *

+ * The types provided by each tag are merely a hint based on the ExifTool Tag Guide by Phil Harvey; the caller is free to parse or + * process the returned {@link String} values any way they wish. + *

Tag Support

+ * ExifTool is capable of parsing almost every tag known to man (1000+), but + * this class makes an attempt at pre-defining a convenient list of the most + * common tags for use. + *

+ * This list was determined by looking at the common metadata tag values + * written to images by popular mobile devices (iPhone, Android) as well as + * cameras like simple point and shoots as well as DSLRs. As an additional + * source of input the list of supported/common EXIF formats that Flickr + * supports was also reviewed to ensure the most common/useful tags were + * being covered here. + *

+ * Please email me or file an issue + * if you think this list is missing a commonly used tag that should be + * added to it. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ + public enum Tag { + ISO("ISO", Integer.class), APERTURE("ApertureValue", Double.class), WHITE_BALANCE( + "WhiteBalance", Integer.class), CONTRAST("Contrast", + Integer.class), SATURATION("Saturation", Integer.class), SHARPNESS( + "Sharpness", Integer.class), SHUTTER_SPEED("ShutterSpeedValue", + Double.class), DIGITAL_ZOOM_RATIO("DigitalZoomRatio", + Double.class), IMAGE_WIDTH("ImageWidth", Integer.class), IMAGE_HEIGHT( + "ImageHeight", Integer.class), X_RESOLUTION("XResolution", + Double.class), Y_RESOLUTION("YResolution", Double.class), FLASH( + "Flash", Integer.class), METERING_MODE("MeteringMode", + Integer.class), FOCAL_LENGTH("FocalLength", Double.class), FOCAL_LENGTH_35MM( + "FocalLengthIn35mmFormat", Integer.class), EXPOSURE_TIME( + "ExposureTime", Double.class), EXPOSURE_COMPENSATION( + "ExposureCompensation", Double.class), EXPOSURE_PROGRAM( + "ExposureProgram", Integer.class), ORIENTATION("Orientation", + Integer.class), COLOR_SPACE("ColorSpace", Integer.class), SENSING_METHOD( + "SensingMethod", Integer.class), SOFTWARE("Software", + String.class), MAKE("Make", String.class), MODEL("Model", + String.class), LENS_MAKE("LensMake", String.class), LENS_MODEL( + "LensModel", String.class), OWNER_NAME("OwnerName", + String.class), TITLE("XPTitle", String.class), AUTHOR( + "XPAuthor", String.class), SUBJECT("XPSubject", String.class), KEYWORDS( + "XPKeywords", String.class), COMMENT("XPComment", String.class), RATING( + "Rating", Integer.class), RATING_PERCENT("RatingPercent", + Integer.class), DATE_TIME_ORIGINAL("DateTimeOriginal", + String.class), CREATION_DATE("CreationDate", String.class), GPS_LATITUDE( + "GPSLatitude", Double.class), GPS_LATITUDE_REF( + "GPSLatitudeRef", String.class), GPS_LONGITUDE("GPSLongitude", + Double.class), GPS_LONGITUDE_REF("GPSLongitudeRef", + String.class), GPS_ALTITUDE("GPSAltitude", Double.class), GPS_ALTITUDE_REF( + "GPSAltitudeRef", Integer.class), GPS_SPEED("GPSSpeed", + Double.class), GPS_SPEED_REF("GPSSpeedRef", String.class), GPS_PROCESS_METHOD( + "GPSProcessingMethod", String.class), GPS_BEARING( + "GPSDestBearing", Double.class), GPS_BEARING_REF( + "GPSDestBearingRef", String.class), GPS_TIMESTAMP( + "GPSTimeStamp", String.class), ROTATION("Rotation",Integer.class), + EXIF_VERSION("ExifVersion",String.class), LENS_ID("LensID",String.class), + COPYRIGHT("Copyright", String.class), ARTIST("Artist", String.class), + SUB_SEC_TIME_ORIGINAL("SubSecTimeOriginal", Integer.class), + SUB_SEC_DATE_TIME_ORIGINAL("SubSecDateTimeOriginal", Integer.class), + OBJECT_NAME("ObjectName", String.class), CAPTION_ABSTRACT("Caption-Abstract", + String.class), CREATOR("Creator", String.class), IPTC_KEYWORDS("Keywords", + String.class), COPYRIGHT_NOTICE("CopyrightNotice", String.class), + FILE_TYPE("FileType", String.class), AVG_BITRATE("AvgBitrate", String.class), + MIME_TYPE("MIMEType", String.class),COMMENTS("Comment", String.class), + CREATE_DATE("CreateDate", Integer.class), + CONTENT_CREATION_DATE("ContentCreateDate", Integer.class); + + private static final Map TAG_LOOKUP_MAP; + + /** + * Initializer used to init the static final tag/name + * lookup map used by all instances of this class. + */ + static { + Tag[] values = Tag.values(); + TAG_LOOKUP_MAP = new HashMap( + values.length * 3); + + for (int i = 0; i < values.length; i++) { + Tag tag = values[i]; + TAG_LOOKUP_MAP.put(tag.name, tag); + } + } + + /** + * Used to get the {@link Tag} identified by the given, case-sensitive, + * tag name. + * + * @param name + * The case-sensitive name of the tag that will be searched + * for. + * + * @return the {@link Tag} identified by the given, case-sensitive, tag + * name or null if one couldn't be found. + */ + public static Tag forName(String name) { + return TAG_LOOKUP_MAP.get(name); + } + + /** + * Convenience method used to convert the given string Tag value + * (returned from the external ExifTool process) into the type described + * by the associated {@link Tag}. + * + * @param + * The type of the returned value. + * @param tag + * The {@link Tag} whose value this is. The tag's type hint + * will be queried to determine how to convert this string + * value. + * @param value + * The {@link String} representation of the tag's value as + * parsed from the image. + * + * @return the given string value converted to a native Java type (e.g. + * Integer, Double, etc.). + * + * @throws IllegalArgumentException + * if tag is null. + * @throws NumberFormatException + * if any exception occurs while trying to parse the given + * value to any of the supported numeric types + * in Java via calls to the respective parseXXX + * methods defined on all the numeric wrapper classes (e.g. + * {@link Integer#parseInt(String)} , + * {@link Double#parseDouble(String)} and so on). + * @throws ClassCastException + * if the type defined by T is incompatible + * with the type defined by {@link Tag#getType()} returned + * by the tag argument passed in. This class + * performs an implicit/unchecked cast to the type + * T before returning the parsed result of the + * type indicated by {@link Tag#getType()}. If the types do + * not match, a ClassCastException will be + * generated by the VM. + */ + @SuppressWarnings("unchecked") + public static T parseValue(Tag tag, String value) + throws IllegalArgumentException, NumberFormatException { + if (tag == null) + throw new IllegalArgumentException("tag cannot be null"); + + T result = null; + + // Check that there is work to do first. + if (value != null) { + Class type = tag.type; + + if (Boolean.class.isAssignableFrom(type)) + result = (T) Boolean.valueOf(value); + else if (Byte.class.isAssignableFrom(type)) + result = (T) Byte.valueOf(Byte.parseByte(value)); + else if (Integer.class.isAssignableFrom(type)) + result = (T) Integer.valueOf(Integer.parseInt(value)); + else if (Short.class.isAssignableFrom(type)) + result = (T) Short.valueOf(Short.parseShort(value)); + else if (Long.class.isAssignableFrom(type)) + result = (T) Long.valueOf(Long.parseLong(value)); + else if (Float.class.isAssignableFrom(type)) + result = (T) Float.valueOf(Float.parseFloat(value)); + else if (Double.class.isAssignableFrom(type)) + result = (T) Double.valueOf(Double.parseDouble(value)); + else if (Character.class.isAssignableFrom(type)) + result = (T) Character.valueOf(value.charAt(0)); + else if (String.class.isAssignableFrom(type)) + result = (T) value; + } + + return result; + } + + /** + * Used to get the name of the tag (e.g. "Orientation", "ISO", etc.). + * + * @return the name of the tag (e.g. "Orientation", "ISO", etc.). + */ + public String getName() { + return name; + } + + /** + * Used to get a hint for the native type of this tag's value as + * specified by Phil Harvey's ExifTool Tag Guide. + * + * @return a hint for the native type of this tag's value. + */ + public Class getType() { + return type; + } + + private String name; + private Class type; + + private Tag(String name, Class type) { + this.name = name; + this.type = type; + } + } + + private Timer cleanupTimer; + private TimerTask currentCleanupTask; + + private IOStream streams; + private List args; + + private Set featureSet; + + @Deprecated + public ExifToolOld1() { + this((Feature[]) null); + } + + @Deprecated + public ExifToolOld1(Feature... features) throws UnsupportedFeatureException { + featureSet = new HashSet(); + + if (features != null && features.length > 0) { + /* + * Process all features to ensure we checked them for support in the + * installed version of ExifTool. If the feature has already been + * checked before, this method will return immediately. + */ + checkFeatureSupport(features); + + /* + * Now we need to verify that all the features requested for this + * instance of ExifTool to use WERE supported after all. + */ + for (int i = 0; i < features.length; i++) { + Feature f = features[i]; + + /* + * If the Feature was supported, record it in the local + * featureSet so this instance knows what features are being + * turned on by the caller. + * + * If the Feature was not supported, throw an exception + * reporting it to the caller so they know it cannot be used. + */ + if (FEATURE_SUPPORT_MAP.get(f).booleanValue()) + featureSet.add(f); + else + throw new UnsupportedFeatureException(f); + } + } + + args = new ArrayList(64); + + /* + * Now that initialization is done, init the cleanup timer if we are + * using STAY_OPEN and the delay time set is non-zero. + */ + if (isFeatureEnabled(Feature.STAY_OPEN) && PROCESS_CLEANUP_DELAY > 0) { + this.cleanupTimer = new Timer(CLEANUP_THREAD_NAME, true); + + // Start the first cleanup task counting down. + resetCleanupTask(); + } + } + + /** + * Used to shutdown the external ExifTool process and close the read/write + * streams used to communicate with it when {@link Feature#STAY_OPEN} is + * enabled. + *

+ * NOTE: Calling this method does not preclude this + * instance of {@link ExifTool} from being re-used, it merely disposes of + * the native and internal resources until the next call to + * getImageMeta causes them to be re-instantiated. + *

+ * The cleanup thread will automatically call this after an interval of + * inactivity defined by {@link #PROCESS_CLEANUP_DELAY}. + *

+ * Calling this method on an instance of this class without + * {@link Feature#STAY_OPEN} support enabled has no effect. + */ + public void close() { + /* + * no-op if the underlying process and streams have already been closed + * OR if stayOpen was never used in the first place in which case + * nothing is open right now anyway. + */ + if (streams == null) + return; + + /* + * If ExifTool was used in stayOpen mode but getImageMeta was never + * called then the streams were never initialized and there is nothing + * to shut down or destroy, otherwise we need to close down all the + * resources in use. + */ + if (streams == null) { + log("\tThis ExifTool instance was never used so no external process or streams were ever created (nothing to clean up, we will just exit)."); + } else { + try { + log("\tAttempting to close ExifTool daemon process, issuing '-stay_open\\nFalse\\n' command..."); + + // Tell the ExifTool process to exit. + streams.writer.write("-stay_open\nFalse\n"); + streams.writer.flush(); + + log("\t\tSuccessful"); + } catch (IOException e) { + e.printStackTrace(); + } finally { + streams.close(); + } + } + + streams = null; + log("\tExifTool daemon process successfully terminated."); + } + + /** + * For {@link ExifTool} instances with {@link Feature#STAY_OPEN} support + * enabled, this method is used to determine if there is currently a running + * ExifTool process associated with this class. + *

+ * Any dependent processes and streams can be shutdown using + * {@link #close()} and this class will automatically re-create them on the + * next call to getImageMeta if necessary. + * + * @return true if there is an external ExifTool process in + * daemon mode associated with this class utilizing the + * {@link Feature#STAY_OPEN} feature, otherwise returns + * false. + */ + public boolean isRunning() { + return (streams != null); + } + + /** + * Used to determine if the given {@link Feature} has been enabled for this + * particular instance of {@link ExifTool}. + *

+ * This method is different from {@link #isFeatureSupported(Feature)}, which + * checks if the given feature is supported by the underlying ExifTool + * install where as this method tells the caller if the given feature has + * been enabled for use in this particular instance. + * + * @param feature + * The feature to check if it has been enabled for us or not on + * this instance. + * + * @return true if the given {@link Feature} is currently + * enabled on this instance of {@link ExifTool}, otherwise returns + * false. + * + * @throws IllegalArgumentException + * if feature is null. + */ + public boolean isFeatureEnabled(Feature feature) + throws IllegalArgumentException { + if (feature == null) + throw new IllegalArgumentException("feature cannot be null"); + + return featureSet.contains(feature); + } + + public Map getImageMeta(File image, Tag... tags) + throws IllegalArgumentException, SecurityException, IOException { + return getImageMeta(image, Format.NUMERIC, tags); + } + + public Map getImageMeta(File image, Format format, Tag... tags) + throws IllegalArgumentException, SecurityException, IOException { + if (image == null) + throw new IllegalArgumentException( + "image cannot be null and must be a valid stream of image data."); + if (format == null) + throw new IllegalArgumentException("format cannot be null"); + if (tags == null || tags.length == 0) + throw new IllegalArgumentException( + "tags cannot be null and must contain 1 or more Tag to query the image for."); + if (!image.canRead()) + throw new SecurityException( + "Unable to read the given image [" + + image.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to read it."); + + long startTime = System.currentTimeMillis(); + + /* + * Create a result map big enough to hold results for each of the tags + * and avoid collisions while inserting. + */ + Map resultMap = new HashMap( + tags.length * 3); + + if (DEBUG) + log("Querying %d tags from image: %s", tags.length, + image.getAbsolutePath()); + + long exifToolCallElapsedTime = 0; + + /* + * Using ExifTool in daemon mode (-stay_open True) executes different + * code paths below. So establish the flag for this once and it is + * reused a multitude of times later in this method to figure out where + * to branch to. + */ + boolean stayOpen = featureSet.contains(Feature.STAY_OPEN); + + // Clear process args + args.clear(); + + if (stayOpen) { + log("\tUsing ExifTool in daemon mode (-stay_open True)..."); + + // Always reset the cleanup task. + resetCleanupTask(); + + /* + * If this is our first time calling getImageMeta with a stayOpen + * connection, set up the persistent process and run it so it is + * ready to receive commands from us. + */ + if (streams == null) { + log("\tStarting daemon ExifTool process and creating read/write streams (this only happens once)..."); + + args.add(EXIF_TOOL_PATH); + args.add("-stay_open"); + args.add("True"); + args.add("-@"); + args.add("-"); + + // Begin the persistent ExifTool process. + streams = startExifToolProcess(args); + } + + log("\tStreaming arguments to ExifTool process..."); + + if (format == Format.NUMERIC) + streams.writer.write("-n\n"); // numeric output + + streams.writer.write("-S\n"); // compact output + + for (int i = 0; i < tags.length; i++) { + streams.writer.write('-'); + streams.writer.write(tags[i].name); + streams.writer.write("\n"); + } + + streams.writer.write(image.getAbsolutePath()); + streams.writer.write("\n"); + + log("\tExecuting ExifTool..."); + + // Begin tracking the duration ExifTool takes to respond. + exifToolCallElapsedTime = System.currentTimeMillis(); + + // Run ExifTool on our file with all the given arguments. + streams.writer.write("-execute\n"); + streams.writer.flush(); + } else { + log("\tUsing ExifTool in non-daemon mode (-stay_open False)..."); + + /* + * Since we are not using a stayOpen process, we need to setup the + * execution arguments completely each time. + */ + args.add(EXIF_TOOL_PATH); + + if (format == Format.NUMERIC) + args.add("-n"); // numeric output + + args.add("-S"); // compact output + + for (int i = 0; i < tags.length; i++) + args.add("-" + tags[i].name); + + args.add(image.getAbsolutePath()); + + // Run the ExifTool with our args. + streams = startExifToolProcess(args); + + // Begin tracking the duration ExifTool takes to respond. + exifToolCallElapsedTime = System.currentTimeMillis(); + } + + log("\tReading response back from ExifTool..."); + + String line = null; + + while ((line = streams.reader.readLine()) != null) { + String[] pair = TAG_VALUE_PATTERN.split(line); + + if (pair != null && pair.length == 2) { + // Determine the tag represented by this value. + Tag tag = Tag.forName(pair[0]); + + /* + * Store the tag and the associated value in the result map only + * if we were able to map the name back to a Tag instance. If + * not, then this is an unknown/unexpected tag return value and + * we skip it since we cannot translate it back to one of our + * supported tags. + */ + if (tag != null) { + resultMap.put(tag, pair[1]); + log("\t\tRead Tag [name=%s, value=%s]", tag.name, pair[1]); + } + } + + /* + * When using a persistent ExifTool process, it terminates its + * output to us with a "{ready}" clause on a new line, we need to + * look for it and break from this loop when we see it otherwise + * this process will hang indefinitely blocking on the input stream + * with no data to read. + */ + if (stayOpen && line.equals("{ready}")) + break; + } + + // Print out how long the call to external ExifTool process took. + log("\tFinished reading ExifTool response in %d ms.", + (System.currentTimeMillis() - exifToolCallElapsedTime)); + + /* + * If we are not using a persistent ExifTool process, then after running + * the command above, the process exited in which case we need to clean + * our streams up since it no longer exists. If we were using a + * persistent ExifTool process, leave the streams open for future calls. + */ + if (!stayOpen) + streams.close(); + + if (DEBUG) + log("\tImage Meta Processed in %d ms [queried %d tags and found %d values]", + (System.currentTimeMillis() - startTime), tags.length, + resultMap.size()); + + return resultMap; + } + + public void setImageMeta(File image, Map tags) + throws IllegalArgumentException, SecurityException, IOException { + setImageMeta(image, Format.NUMERIC, tags); + } + + public void setImageMeta(File image, Format format, Map tags ) + throws IllegalArgumentException, SecurityException, IOException { + if (image == null) + throw new IllegalArgumentException( + "image cannot be null and must be a valid stream of image data."); + if (format == null) + throw new IllegalArgumentException("format cannot be null"); + if (tags == null || tags.size() == 0) + throw new IllegalArgumentException( + "tags cannot be null and must contain 1 or more Tag to query the image for."); + if (!image.canWrite()) + throw new SecurityException( + "Unable to read the given image [" + + image.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to read it."); + + long startTime = System.currentTimeMillis(); + + if (DEBUG) + log("Writing %d tags to image: %s", tags.size(), + image.getAbsolutePath()); + + long exifToolCallElapsedTime = 0; + + /* + * Using ExifTool in daemon mode (-stay_open True) executes different + * code paths below. So establish the flag for this once and it is + * reused a multitude of times later in this method to figure out where + * to branch to. + */ + boolean stayOpen = featureSet.contains(Feature.STAY_OPEN); + + // Clear process args + args.clear(); + + if (stayOpen) { + log("\tUsing ExifTool in daemon mode (-stay_open True)..."); + + // Always reset the cleanup task. + resetCleanupTask(); + + /* + * If this is our first time calling getImageMeta with a stayOpen + * connection, set up the persistent process and run it so it is + * ready to receive commands from us. + */ + if (streams == null) { + log("\tStarting daemon ExifTool process and creating read/write streams (this only happens once)..."); + + args.add(EXIF_TOOL_PATH); + args.add("-stay_open"); + args.add("True"); + args.add("-@"); + args.add("-"); + + // Begin the persistent ExifTool process. + streams = startExifToolProcess(args); + } + + log("\tStreaming arguments to ExifTool process..."); + + if (format == Format.NUMERIC) + streams.writer.write("-n\n"); // numeric output + + streams.writer.write("-S\n"); // compact output + + for ( Entry entry :tags.entrySet() ) { + streams.writer.write('-'); + streams.writer.write(entry.getKey().name); + streams.writer.write("='"); + streams.writer.write(entry.getValue()); + streams.writer.write("'\n"); + } + + streams.writer.write(image.getAbsolutePath()); + streams.writer.write("\n"); + + log("\tExecuting ExifTool..."); + + // Begin tracking the duration ExifTool takes to respond. + exifToolCallElapsedTime = System.currentTimeMillis(); + + // Run ExifTool on our file with all the given arguments. + streams.writer.write("-execute\n"); + streams.writer.flush(); + } else { + log("\tUsing ExifTool in non-daemon mode (-stay_open False)..."); + + /* + * Since we are not using a stayOpen process, we need to setup the + * execution arguments completely each time. + */ + args.add(EXIF_TOOL_PATH); + + if (format == Format.NUMERIC) + args.add("-n"); // numeric output + + args.add("-S"); // compact output + + for ( Entry entry :tags.entrySet() ) + args.add("-" + entry.getKey().name + "='" + entry.getValue() + "'" ); + + args.add(image.getAbsolutePath()); + + // Run the ExifTool with our args. + streams = startExifToolProcess(args); + + // Begin tracking the duration ExifTool takes to respond. + exifToolCallElapsedTime = System.currentTimeMillis(); + } + + log("\tReading response back from ExifTool..."); + + String line = null; + + while ((line = streams.reader.readLine()) != null) { + /* + * When using a persistent ExifTool process, it terminates its + * output to us with a "{ready}" clause on a new line, we need to + * look for it and break from this loop when we see it otherwise + * this process will hang indefinitely blocking on the input stream + * with no data to read. + */ + if (stayOpen && line.equals("{ready}")) + break; + } + + // Print out how long the call to external ExifTool process took. + log("\tFinished reading ExifTool response in %d ms.", + (System.currentTimeMillis() - exifToolCallElapsedTime)); + + /* + * If we are not using a persistent ExifTool process, then after running + * the command above, the process exited in which case we need to clean + * our streams up since it no longer exists. If we were using a + * persistent ExifTool process, leave the streams open for future calls. + */ + if (!stayOpen) + streams.close(); + + if (DEBUG) + log("\tImage Meta Processed in %d ms [write %d tags]", + (System.currentTimeMillis() - startTime), tags.size()); + } + + /** + * Helper method used to make canceling the current task and scheduling a + * new one easier. + *

+ * It is annoying that we cannot just reset the timer on the task, but that + * isn't the way the java.util.Timer class was designed unfortunately. + */ + private void resetCleanupTask() { + // no-op if the timer was never created. + if (cleanupTimer == null) + return; + + log("\tResetting cleanup task..."); + + // Cancel the current cleanup task if necessary. + if (currentCleanupTask != null) + currentCleanupTask.cancel(); + + // Schedule a new cleanup task. + cleanupTimer.schedule( + (currentCleanupTask = new CleanupTimerTask(this)), + PROCESS_CLEANUP_DELAY, PROCESS_CLEANUP_DELAY); + + log("\t\tSuccessful"); + } + + /** + * Class used to represent the {@link TimerTask} used by the internal auto + * cleanup {@link Timer} to call {@link ExifTool#close()} after a specified + * interval of inactivity. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ + private class CleanupTimerTask extends TimerTask { + private ExifToolOld1 owner; + + public CleanupTimerTask(ExifToolOld1 owner) throws IllegalArgumentException { + if (owner == null) + throw new IllegalArgumentException( + "owner cannot be null and must refer to the ExifTool instance creating this task."); + + this.owner = owner; + } + + @Override + public void run() { + log("\tAuto cleanup task running..."); + owner.close(); + } + } + + /** + * Class used to define an exception that occurs when the caller attempts to + * use a {@link Feature} that the underlying native ExifTool install does + * not support (i.e. the version isn't new enough). + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ + public class UnsupportedFeatureException extends RuntimeException { + private static final long serialVersionUID = -1332725983656030770L; + + private Feature feature; + + public UnsupportedFeatureException(Feature feature) { + super( + "Use of feature [" + + feature + + "] requires version " + + feature.version + + " or higher of the native ExifTool program. The version of ExifTool referenced by the system property 'exiftool.path' is not high enough. You can either upgrade the install of ExifTool or avoid using this feature to workaround this exception."); + } + + public Feature getFeature() { + return feature; + } + } +} diff --git a/src/main/java/com/thebuzzmedia/exiftool/Feature.java b/src/main/java/com/thebuzzmedia/exiftool/Feature.java new file mode 100644 index 00000000..e6c04931 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/Feature.java @@ -0,0 +1,63 @@ +package com.thebuzzmedia.exiftool; + +// ================================================================================ +/** + * Enum used to define the different kinds of features in the native + * ExifToolNew3 executable that this class can help you take advantage of. + *

+ * These flags are different from {@link Tag}s in that a "feature" is + * determined to be a special functionality of the underlying ExifToolNew3 + * executable that requires a different code-path in this class to take + * advantage of; for example, -stay_open True support. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +public enum Feature { + /** + * Enum used to specify that you wish to launch the underlying ExifToolNew3 + * process with -stay_open True support turned on that this + * class can then take advantage of. + *

+ * Required ExifToolNew3 version is 8.36 or higher. + */ + STAY_OPEN(8, 36), + /** + * Acitves the MWG modules. The Metadata Working Group (MWG) recommends + * techniques to allow certain overlapping EXIF, IPTC and XMP tags to be + * reconciled when reading, and synchronized when writing. The MWG Composite + * tags below are designed to aid in the implementation of these + * recommendations. Will add the args " -use MWG" + * + * @see ExifToolNew3 + * MWG Docs !! Note these version numbers are not correct + */ + MWG_MODULE(8, 36), + /** + * Enable charset windows-1252 to be able to properly pass file names in windows with keep alive + */ + WINDOWS(8, 36), + ; + + private VersionNumber requireVersion; + + private Feature(int... numbers) { + this.requireVersion = new VersionNumber(numbers); + } + + /** + * Used to get the version of ExifToolNew3 required by this feature in order + * to work. + * + * @return the version of ExifToolNew3 required by this feature in order to + * work. + */ + VersionNumber getVersion() { + return requireVersion; + } + + boolean isSupported(VersionNumber exifVersionNumber) { + return requireVersion.isBeforeOrEqualTo(exifVersionNumber); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/Format.java b/src/main/java/com/thebuzzmedia/exiftool/Format.java new file mode 100644 index 00000000..7fd096c7 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/Format.java @@ -0,0 +1,41 @@ +package com.thebuzzmedia.exiftool; + +import java.io.File; + +/** + * Enum used to define the 2 different output formats that {@link Tag} values + * can be returned in: numeric or human-readable text. + *

+ * ExifToolNew3, via the -n command line arg, is capable of returning + * most values in their raw numeric form (e.g. Aperture="2.8010323841") as well + * as a more human-readable/friendly format (e.g. Aperture="2.8"). + *

+ * While the {@link Tag}s defined on this class do provide a hint at the type of + * the result (see {@link Tag#getType()}), that hint only applies when the + * {@link Format#NUMERIC} form of the value is returned. + *

+ * If the caller finds the human-readable format easier to process, + * {@link Format#HUMAN_READABLE} can be specified when calling + * {@link ExifToolNew3#getImageMeta4(File, ReadOptions, Format, Tag...)} and the returned + * {@link String} values processed manually by the caller. + *

+ * In order to see the types of values that are returned when + * {@link Format#HUMAN_READABLE} is used, you can check the comprehensive + * ExifToolNew3 Tag Guide. + *

+ * This makes sense with some values like Aperture that in + * {@link Format#NUMERIC} format end up returning as 14-decimal-place, high + * precision values that are near the intended value (e.g. "2.79999992203711" + * instead of just returning "2.8"). On the other hand, other values (like + * Orientation) are easier to parse when their numeric value (1-8) is returned + * instead of a much longer friendly name (e.g. + * "Mirror horizontal and rotate 270 CW"). + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +public enum Format { + NUMERIC, + HUMAN_READABLE; +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/IOStream.java b/src/main/java/com/thebuzzmedia/exiftool/IOStream.java new file mode 100644 index 00000000..707a0feb --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/IOStream.java @@ -0,0 +1,50 @@ +package com.thebuzzmedia.exiftool; + +import java.io.BufferedReader; +import java.io.OutputStreamWriter; + +/** + * Simple class used to house the read/write streams used to communicate with an + * external ExifToolNew3 process as well as the logic used to safely close the + * streams when no longer needed. + *

+ * This class is just a convenient way to group and manage the read/write + * streams as opposed to making them dangling member variables off of ExifToolNew3 + * directly. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +class IOStream { + BufferedReader reader; + OutputStreamWriter writer; + + public IOStream(BufferedReader reader, OutputStreamWriter writer) { + this.reader = reader; + this.writer = writer; + } + + public void close() { + try { + ExifToolNew3.log("\tClosing Read stream..."); + reader.close(); + ExifToolNew3.log("\t\tSuccessful"); + } catch (Exception e) { + // no-op, just try to close it. + } + + try { + ExifToolNew3.log("\tClosing Write stream..."); + writer.close(); + ExifToolNew3.log("\t\tSuccessful"); + } catch (Exception e) { + // no-op, just try to close it. + } + + // Null the stream references. + reader = null; + writer = null; + + ExifToolNew3.log("\tRead/Write streams successfully closed."); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/KeepAliveExifProxy.java b/src/main/java/com/thebuzzmedia/exiftool/KeepAliveExifProxy.java new file mode 100644 index 00000000..f3df31c5 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/KeepAliveExifProxy.java @@ -0,0 +1,135 @@ +package com.thebuzzmedia.exiftool; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Manages an external exif process in keep alive mode. + */ +public class KeepAliveExifProxy implements ExifProxy { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(KeepAliveExifProxy.class); + private final List startupArgs; + private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + private final Timer cleanupTimer = new Timer(ExifToolNew3.CLEANUP_THREAD_NAME, true); + private final long inactivityTimeout; + private volatile long lastRunStart = 0; + private volatile ExifProcess process; + private final Charset charset; + + public KeepAliveExifProxy(String exifCmd, List baseArgs, long inactivityTimeoutParam) { + this(exifCmd, baseArgs, inactivityTimeoutParam, ExifToolNew3.computeDefaultCharset(EnumSet + .noneOf(Feature.class))); + } + + public KeepAliveExifProxy(String exifCmd, List baseArgs, Charset charset) { + this(exifCmd, baseArgs, Long.getLong(ExifToolNew.ENV_EXIF_TOOL_PROCESSCLEANUPDELAY, + ExifToolNew.DEFAULT_PROCESS_CLEANUP_DELAY), charset); + } + + public KeepAliveExifProxy(String exifCmd, List baseArgs, long inactivityTimeoutParam, Charset charset) { + this.inactivityTimeout = inactivityTimeoutParam; + startupArgs = new ArrayList(baseArgs.size() + 5); + startupArgs.add(exifCmd); + startupArgs.addAll(Arrays.asList("-stay_open", "True")); + startupArgs.addAll(baseArgs); + startupArgs.addAll(Arrays.asList("-@", "-")); + this.charset = charset; + // runs every minute to check if process has been inactive too long + if (inactivityTimeout != 0) { + cleanupTimer.schedule(new TimerTask() { + @Override + public void run() { + if (process != null && lastRunStart > 0 && inactivityTimeout > 0) { + if ((System.currentTimeMillis() - lastRunStart) > inactivityTimeout) { + synchronized (this) { + if (process != null) { + process.close(); + } + } + } + } else if (lastRunStart == 0) { + shutdown(); + } + } + }, inactivityTimeout + // 60 * 1000// every minute + ); + } + } + + @Override + public void startup() { + shuttingDown.set(false); + if (process == null || process.isClosed()) { + synchronized (this) { + if (process == null || process.isClosed()) { + LOG.debug("Starting daemon ExifToolNew3 process and creating read/write streams (this only happens once)..."); + process = new ExifProcess(true, startupArgs, charset); + } + } + } + } + + @Override + public List execute(final long runTimeoutMills, List args) { + lastRunStart = System.currentTimeMillis(); + int attempts = 0; + while (attempts < 3 && !shuttingDown.get()) { + attempts++; + if (process == null || process.isClosed()) { + synchronized (this) { + if (process == null || process.isClosed()) { + LOG.debug("Starting daemon ExifToolNew3 process and creating read/write streams (this only happens once)..."); + process = new ExifProcess(true, startupArgs, charset); + } + } + } + TimerTask attemptTimer = null; + try { + if (runTimeoutMills > 0) { + attemptTimer = new TimerTask() { + @Override + public void run() { + if (process != null && !process.isClosed()) { + LOG.warn("Process ran too long closing, max " + runTimeoutMills + " mills"); + process.close(); + } + } + }; + cleanupTimer.schedule(attemptTimer, runTimeoutMills); + } + LOG.debug("Streaming arguments to ExifToolNew3 process..."); + return process.sendToRunning(args); + } catch (IOException ex) { + if (ExifToolNew3.STREAM_CLOSED_MESSAGE.equals(ex.getMessage()) && !shuttingDown.get()) { + // only catch "Stream Closed" error (happens when + // process has died) + LOG.warn(String.format("Caught IOException(\"%s\"), will restart daemon", + ExifToolNew3.STREAM_CLOSED_MESSAGE)); + process.close(); + } else { + throw new RuntimeException(ex); + } + } finally { + if (attemptTimer != null) + attemptTimer.cancel(); + } + } + if (shuttingDown.get()) { + throw new RuntimeException("Shutting Down"); + } + throw new RuntimeException("Ran out of attempts"); + } + + @Override + public boolean isRunning() { + return process != null && !process.isClosed(); + } + + @Override + public void shutdown() { + shuttingDown.set(true); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/LineReaderThread.java b/src/main/java/com/thebuzzmedia/exiftool/LineReaderThread.java new file mode 100644 index 00000000..de9e1ebf --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/LineReaderThread.java @@ -0,0 +1,89 @@ +package com.thebuzzmedia.exiftool; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +/** + * A Thread which reads lines from a BufferedReader and puts them in a queue, so + * they can be read from with out blocking. This is used when reading from a + * process.err input. + * + * Remember to start thread!! + * + * @author msgile + * @author $LastChangedBy$ + * @version $Revision$ $LastChangedDate$ + * @since 7/25/14 + */ +public class LineReaderThread extends Thread { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(LineReaderThread.class); + private BufferedReader reader; + private BlockingQueue lineBuffer = new ArrayBlockingQueue( + 50, true); + + public LineReaderThread(String name, BufferedReader reader) { + super(name); + this.reader = reader; + } + + public LineReaderThread(ThreadGroup group, String name, + BufferedReader reader) { + super(group, name); + this.reader = reader; + } + + @Override + public void run() { + String line; + try { + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (!line.isEmpty()) { + lineBuffer.put(line); + } + } + } catch (IOException ex) { + LOG.warn("Error in LineReaderThread.",ex); + } catch (InterruptedException ignored) { + LOG.debug("er:",ignored); + } + } + + public boolean isEmpty() { + return lineBuffer.isEmpty(); + } + + public boolean hasLines() { + return !lineBuffer.isEmpty(); + } + + /** + * Takes all lines from the buffer or returns empty list. + */ + public List takeLines() { + List lines = new ArrayList(lineBuffer.size()); + lineBuffer.drainTo(lines); + return lines; + } + + /** + * Reads line without blocking, will return Null if no lines in buffer. + */ + public String readLine() { + return lineBuffer.poll(); + } + + public void close() { + interrupt(); + try { + reader.close(); + } catch (IOException ignored) { + ; + } + } + +} diff --git a/src/main/java/com/thebuzzmedia/exiftool/MetadataTag.java b/src/main/java/com/thebuzzmedia/exiftool/MetadataTag.java new file mode 100644 index 00000000..8a3a83ba --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/MetadataTag.java @@ -0,0 +1,24 @@ +package com.thebuzzmedia.exiftool; + +// ================================================================================ +/** + * Base type for all "tag" passed to exiftool. The key is the value passed to + * the exiftool like "-creator". The Types is used for automatic type + * conversions. + * + */ +public interface MetadataTag { + /** + * Returns the values passed to exiftool + */ + public String getKey(); + + /** + * The types + */ + public Class getType(); + + public boolean isMapped(); + + public String toExif(T value); +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/MwgTag.java b/src/main/java/com/thebuzzmedia/exiftool/MwgTag.java new file mode 100644 index 00000000..6972caa9 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/MwgTag.java @@ -0,0 +1,64 @@ +package com.thebuzzmedia.exiftool; + +import java.util.Date; +import java.util.Map; + +// ================================================================================ +public enum MwgTag implements MetadataTag { + LOCATION("Location", String.class), + CITY("City", String.class), + STATE("State", String.class), + COUNTRY("Country", String.class), + + COPYRIGHT("Copyright", String.class), + + DATE_TIME_ORIGINAL("DateTimeOriginal", Date.class), + CREATE_DATE("CreateDate", Date.class), + MODIFY_DATE("ModifyDate", Date.class), + + CREATOR("Creator", String.class), + DESCRIPTION("Description", String.class), + KEYWORDS("Keywords", String[].class), + + ORIENTATION("Orientation", Integer.class), + RATING("Rating", Integer.class), + + ; + + private String name; + private Class type; + + private MwgTag(String name, Class type) { + this.name = name; + this.type = type; + } + + @Override + public String getKey() { + return name; + } + + @Override + public Class getType() { + return type; + } + + @Override + public boolean isMapped() { + return true; + } + + @Override + public String toString() { + return name; + } + @SuppressWarnings("unchecked") + public V2 getValue(Map metadata) { + return (V2) Tag.parseValue(this, metadata.get(this)); + } + + @Override + public String toExif(T value) { + return Tag.toExif(this, value); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/RawExifTool.java b/src/main/java/com/thebuzzmedia/exiftool/RawExifTool.java new file mode 100644 index 00000000..098b2f2b --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/RawExifTool.java @@ -0,0 +1,148 @@ +package com.thebuzzmedia.exiftool; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import com.thebuzzmedia.exiftool.adapters.ExifToolService; + +/** + * Interface that retrieves the raw data from exiftool. Adding semantic oriented details like, types, format etc should + * be done outside of this class. + */ +public interface RawExifTool extends AutoCloseable { + /** + * Factory method with "best" ExifToolService implementation. + * + * @author raisercostin + */ + public static class Factory { + public static ExifToolService create(Feature... features) { + return new ExifToolService(new ExifToolNew3(features)); + // return new ExifToolNew2(features); + // return new ExifToolNew(features); + } + + public static ExifToolService create(int timeoutWhenKeepAliveInMillis, Feature... features) { + return new ExifToolService(new ExifToolNew3(timeoutWhenKeepAliveInMillis, features)); + // return new ExifToolNew(timeoutWhenKeepAliveInMillis, features); + // return new ExifToolNew2(timeoutWhenKeepAliveInMillis, features); + } + + public static ExifToolService create(ReadOptions readOptions, Feature... features) { + // ignore readOptions + return new ExifToolService(new ExifToolNew3(features)); + } + } + + /** + * Used to determine if the given {@link Feature} is supported by the underlying native install of ExifToolNew3 + * pointed at by {@link #exifCmd}. + *

+ * If support for the given feature has not been checked for yet, this method will automatically call out to + * ExifToolNew3 and ensure the requested feature is supported in the current local install. + *

+ * The external call to ExifToolNew3 to confirm feature support is only ever done once per JVM session and stored in + * a static final {@link Map} that all instances of this class share. + * + * @param feature + * The feature to check support for in the underlying ExifToolNew3 install. + * + * @return true if support for the given {@link Feature} was confirmed to work with the currently + * installed ExifToolNew3 or false if it is not supported. + * + * @throws IllegalArgumentException + * if feature is null. + * @throws RuntimeException + * if any exception occurs while attempting to start the external ExifToolNew3 process to verify feature + * support. + */ + boolean isFeatureSupported(Feature feature) throws RuntimeException; + + /** + * Used to startup the external ExifToolNew3 process and open the read/write streams used to communicate with it + * when {@link Feature#STAY_OPEN} is enabled. This method has no effect if the stay open feature is not enabled. + */ +// void startup(); + + /** + * This is same as {@link #close()}, added for consistency with {@link #startup()} + */ + void shutdown(); + + /** + * Used to shutdown the external ExifToolNew3 process and close the read/write streams used to communicate with it + * when {@link Feature#STAY_OPEN} is enabled. + *

+ * NOTE: Calling this method does not preclude this instance of {@link ExifToolNew3} from being + * re-used, it merely disposes of the native and internal resources until the next call to getImageMeta + * causes them to be re-instantiated. + *

+ * The cleanup thread will automatically call this after an interval of inactivity defined by + * {@link #processCleanupDelay}. + *

+ * Calling this method on an instance of this class without {@link Feature#STAY_OPEN} support enabled has no effect. + */ + void close(); + + /** + * Using ExifToolNew3 in daemon mode (-stay_open True) executes different code paths below. So establish the flag + * for this once and it is reused a multitude of times later in this method to figure out where to branch to. + */ + boolean isStayOpen(); + + /** + * For {@link ExifToolNew3} instances with {@link Feature#STAY_OPEN} support enabled, this method is used to + * determine if there is currently a running ExifToolNew3 process associated with this class. + *

+ * Any dependent processes and streams can be shutdown using {@link #close()} and this class will automatically + * re-create them on the next call to getImageMeta if necessary. + * + * @return true if there is an external ExifToolNew3 process in daemon mode associated with this class + * utilizing the {@link Feature#STAY_OPEN} feature, otherwise returns false. + */ + boolean isRunning(); + + /** + * Used to determine if the given {@link Feature} has been enabled for this particular instance of + * {@link ExifToolNew3}. + *

+ * This method is different from {@link #isFeatureSupported(Feature)}, which checks if the given feature is + * supported by the underlying ExifToolNew3 install where as this method tells the caller if the given feature has + * been enabled for use in this particular instance. + * + * @param feature + * The feature to check if it has been enabled for us or not on this instance. + * + * @return true if the given {@link Feature} is currently enabled on this instance of + * {@link ExifToolNew3}, otherwise returns false. + * + * @throws IllegalArgumentException + * if feature is null. + */ + boolean isFeatureEnabled(Feature feature) throws IllegalArgumentException; + + public void addImageMetadata(File file, Map values) throws IOException; + + /** + * Takes a map of tags (either (@link Tag) or Strings for keys) and replaces/appends them to the metadata. + */ + void writeMetadata(WriteOptions options, File file, Map values) throws IOException; + + void rebuildMetadata(File file) throws IOException; + + /** + * Rewrite all the the metadata tags in a JPEG image. This will not work for TIFF files. Use this when the image has + * some corrupt tags. + * + * @link http://www.sno.phy.queensu.ca/~phil/exiftool/faq.html#Q20 + */ + void rebuildMetadata(WriteOptions options, File file) throws IOException; + + /** If no tags are given return all tags. */ + Map getImageMeta(File file, ReadOptions readOptions, String... tags) + throws IOException; + + List execute(List args); +} diff --git a/src/main/java/com/thebuzzmedia/exiftool/ReadOptions.java b/src/main/java/com/thebuzzmedia/exiftool/ReadOptions.java new file mode 100644 index 00000000..eeec2b12 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/ReadOptions.java @@ -0,0 +1,108 @@ +package com.thebuzzmedia.exiftool; + +// ================================================================================ +/** + * All the read options, is immutable, copy on change, fluent style "with" + * setters. + */ +public class ReadOptions { + public final long runTimeoutMills; + public final boolean convertTypes; + public final boolean numericOutput; + public final boolean showDuplicates; + public final boolean showEmptyTags; + + public ReadOptions() { + this(0, false, false, false, false); + } + + private ReadOptions(long runTimeoutMills, boolean convertTypes, + boolean numericOutput, boolean showDuplicates, boolean showEmptyTags) { + this.runTimeoutMills = runTimeoutMills; + this.convertTypes = convertTypes; + this.numericOutput = numericOutput; + this.showDuplicates = showDuplicates; + this.showEmptyTags = showEmptyTags; + } + + @Override + public String toString() { + return String + .format("%s(runTimeout:%,d convertTypes:%s showDuplicates:%s showEmptyTags:%s)", + getClass().getSimpleName(), runTimeoutMills, + convertTypes, showDuplicates, showEmptyTags); + } + + /** + * Sets the maximum time a process can run + */ + public ReadOptions withRunTimeoutMills(long mills) { + return new ReadOptions(mills, convertTypes, numericOutput, + showDuplicates, showEmptyTags); + } + + /** + * By default all values will be returned as the strings printed by the + * exiftool. If this is enabled then {@link MetadataTag#getType()} is used + * to cast the string into a java type. + */ + public ReadOptions withConvertTypes(boolean enabled) { + return new ReadOptions(runTimeoutMills, enabled, numericOutput, + showDuplicates, showEmptyTags); + } + + /** + * Setting this to true will add the "-n" option causing the ExifToolNew3 to + * output of some tags to change. + *

+ * ExifToolNew3, via the -n command line arg, is capable of + * returning most values in their raw numeric form (e.g. + * Aperture="2.8010323841") as well as a more human-readable/friendly format + * (e.g. Aperture="2.8"). + *

+ * While the {@link Tag}s defined on this class do provide a hint at the + * type of the result (see {@link MetadataTag#getType()}), that hint only + * applies when the numeric value is returned. + *

+ * If the caller finds the human-readable format easier to process, Set this + * to false, the default. + *

+ * In order to see the types of values that are returned when human readable + * is used (default), you can check the comprehensive + * ExifToolNew3 Tag Guide. + *

+ * This makes sense with some values like Aperture that in numeric format + * end up returning as 14-decimal-place, high precision values that are near + * the intended value (e.g. "2.79999992203711" instead of just returning + * "2.8"). On the other hand, other values (like Orientation) are easier to + * parse when their numeric value (1-8) is returned instead of a much longer + * friendly name (e.g. "Mirror horizontal and rotate 270 CW"). + * + * Attempted from work done by + * + * @author Riyad Kalla (software@thebuzzmedia.com) + */ + public ReadOptions withNumericOutput(boolean enabled) { + return new ReadOptions(runTimeoutMills, convertTypes, enabled, + showDuplicates, showEmptyTags); + } + + public ReadOptions withNumericOutput(Format format) { + return withNumericOutput(format==Format.NUMERIC); + } + + /** + * If enabled will show tags which are duplicated between different tag + * regions, relates to the "-a" option in ExifToolNew3. + */ + public ReadOptions withShowDuplicates(boolean enabled) { + return new ReadOptions(runTimeoutMills, convertTypes, numericOutput, + enabled, showEmptyTags); + } + + public ReadOptions withShowEmptyTags(boolean enabled) { + return new ReadOptions(runTimeoutMills, convertTypes, numericOutput, + showDuplicates, enabled); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/Sidecar.java b/src/main/java/com/thebuzzmedia/exiftool/Sidecar.java new file mode 100644 index 00000000..45328e86 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/Sidecar.java @@ -0,0 +1,213 @@ +package com.thebuzzmedia.exiftool; + +import java.io.File; +import java.io.IOException; + +/** + * Class used to handle XMP Sidecar files using exiftool. + * + * @author Clinton LaForest (clafore@bgsu.edu) + * @since 1.2_thespiritx + */ +public class Sidecar { + /** + * + */ + private final ExifToolNew2 exifTool; + + public Sidecar(ExifToolNew2 exifTool, Feature feature) { + this.exifTool = exifTool; + + } + + /** + * Used to merge a XMP Sidecar file with an image. + * + * @param xmp + * The xmp sidecar file. + * + * @param file + * The image file. + * + * @param preserve + * true - preserves name mappings false + * - uses preferred name mappings + * + * @return void + * + */ + public void merge(File xmp, File file, Boolean preserve) { + if (xmp == null) + throw new IllegalArgumentException( + "xmp cannot be null and must be a valid xmp sidecar stream."); + if (file == null) + throw new IllegalArgumentException("file cannot be null"); + if (preserve == null) + preserve = false; + if (!file.canWrite()) + throw new SecurityException( + "Unable to read the given image [" + + file.getAbsolutePath() + + "], ensure that the image exists at the given path and that the executing Java process has permissions to read it."); + + long startTime = System.currentTimeMillis(); + + ExifToolNew3.log("Writing %s tags to image: %s", xmp.getAbsolutePath(), + file.getAbsolutePath()); + + long exifToolCallElapsedTime = 0; + + /* + * Using ExifToolNew3 in daemon mode (-stay_open True) executes different + * code paths below. So establish the flag for this once and it is + * reused a multitude of times later in this method to figure out where + * to branch to. + */ + boolean stayOpen = this.exifTool.featureSet.contains(Feature.STAY_OPEN); + + this.exifTool.args.clear(); + + if (stayOpen) { + ExifToolNew3.log("\tUsing ExifToolNew3 in daemon mode (-stay_open True)..."); + + // Always reset the cleanup task. + this.exifTool.resetCleanupTask(); + + /* + * If this is our first time calling getImageMeta with a stayOpen + * connection, set up the persistent process and run it so it is + * ready to receive commands from us. + */ + if (this.exifTool.streams == null) { + ExifToolNew3.log("\tStarting daemon ExifToolNew3 process and creating read/write streams (this only happens once)..."); + + this.exifTool.args.add(exifTool.EXIF_TOOL_PATH); + this.exifTool.args.add("-stay_open"); + this.exifTool.args.add("True"); + this.exifTool.args.add("-@"); + this.exifTool.args.add("-"); + + // Begin the persistent ExifToolNew3 process. + this.exifTool.streams = exifTool + .startExifToolProcess(this.exifTool.args); + } + + ExifToolNew3.log("\tStreaming arguments to ExifToolNew3 process..."); + + try { + this.exifTool.streams.writer.write("-tagsfromfile\n"); + + this.exifTool.streams.writer.write(file.getAbsolutePath()); + this.exifTool.streams.writer.write("\n"); + + if (preserve) { + this.exifTool.streams.writer.write("-all:all"); + this.exifTool.streams.writer.write("\n"); + } else { + this.exifTool.streams.writer.write("-xmp"); + this.exifTool.streams.writer.write("\n"); + } + + this.exifTool.streams.writer.write(xmp.getAbsolutePath()); + this.exifTool.streams.writer.write("\n"); + + ExifToolNew3.log("\tExecuting ExifToolNew3..."); + + // Begin tracking the duration ExifToolNew3 takes to respond. + exifToolCallElapsedTime = System.currentTimeMillis(); + + // Run ExifToolNew3 on our file with all the given arguments. + this.exifTool.streams.writer.write("-execute\n"); + this.exifTool.streams.writer.flush(); + + } catch (IOException e) { + ExifToolNew3.log("\tError received in stayopen stream: %s", + e.getMessage()); + } // compact output + } else { + ExifToolNew3.log("\tUsing ExifToolNew3 in non-daemon mode (-stay_open False)..."); + + /* + * Since we are not using a stayOpen process, we need to setup the + * execution arguments completely each time. + */ + this.exifTool.args.add(exifTool.EXIF_TOOL_PATH); + + this.exifTool.args.add("-tagsfromfile"); // compact output + + this.exifTool.args.add(file.getAbsolutePath()); + + if (preserve) { + this.exifTool.args.add("-all:all"); + } else { + this.exifTool.args.add("-xmp"); + } + + this.exifTool.args.add(xmp.getAbsolutePath()); + + // Run the ExifToolNew3 with our args. + this.exifTool.streams = exifTool + .startExifToolProcess(this.exifTool.args); + + // Begin tracking the duration ExifToolNew3 takes to respond. + exifToolCallElapsedTime = System.currentTimeMillis(); + } + + ExifToolNew3.log("\tReading response back from ExifToolNew3..."); + + String line = null; + + try { + while ((line = this.exifTool.streams.reader.readLine()) != null) { + /* + * When using a persistent ExifToolNew3 process, it terminates its + * output to us with a "{ready}" clause on a new line, we need + * to look for it and break from this loop when we see it + * otherwise this process will hang indefinitely blocking on the + * input stream with no data to read. + */ + if (stayOpen && line.equals("{ready}")) + break; + } + } catch (IOException e) { + ExifToolNew3.log("\tError received in response: %d", e.getMessage()); + } + + // Print out how long the call to external ExifToolNew3 process took. + ExifToolNew3.log("\tFinished reading ExifToolNew3 response in %d ms.", + (System.currentTimeMillis() - exifToolCallElapsedTime)); + + /* + * If we are not using a persistent ExifToolNew3 process, then after running + * the command above, the process exited in which case we need to clean + * our streams up since it no longer exists. If we were using a + * persistent ExifToolNew3 process, leave the streams open for future calls. + */ + if (!stayOpen) + this.exifTool.streams.close(); + + ExifToolNew3.log("\tImage Meta Processed in %d ms [write %s tags]", + (System.currentTimeMillis() - startTime), xmp.getAbsolutePath()); + + } + + /** + * Used to export a XMP Sidecar file from an image. + * + * @param xmp + * The xmp sidecar file. + * + * @param file + * The image file. + * + * @param preserve + * true - preserves name mappings false + * - uses preferred name mappings + * + * @return void + * + */ + public void export(File xmp, File file, boolean preserve) { + + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/SingleUseExifProxy.java b/src/main/java/com/thebuzzmedia/exiftool/SingleUseExifProxy.java new file mode 100644 index 00000000..f0254adb --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/SingleUseExifProxy.java @@ -0,0 +1,68 @@ +package com.thebuzzmedia.exiftool; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.*; + +public class SingleUseExifProxy implements ExifProxy { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(SingleUseExifProxy.class); + private final Timer cleanupTimer = new Timer(ExifToolNew3.CLEANUP_THREAD_NAME, true); + private final List baseArgs; + private final Charset charset; + + + public SingleUseExifProxy(String exifCmd, List defaultArgs) { + this(exifCmd,defaultArgs,ExifToolNew3.computeDefaultCharset(EnumSet.noneOf(Feature.class))); + } + public SingleUseExifProxy(String exifCmd, List defaultArgs, Charset charset) { + this.baseArgs = new ArrayList(defaultArgs.size() + 1); + this.baseArgs.add(exifCmd); + this.baseArgs.addAll(defaultArgs); + this.charset = charset; + } + + @Override + public List execute(final long runTimeoutMills, List args) { + List newArgs = new ArrayList(baseArgs.size() + args.size()); + newArgs.addAll(baseArgs); + newArgs.addAll(args); + final ExifProcess process = new ExifProcess(false, newArgs, charset); + TimerTask attemptTimer = null; + if (runTimeoutMills > 0) { + attemptTimer = new TimerTask() { + @Override + public void run() { + if (!process.isClosed()) { + LOG.warn("Process ran too long closing, max " + runTimeoutMills + " mills"); + process.close(); + } + } + }; + cleanupTimer.schedule(attemptTimer, runTimeoutMills); + } + try { + return process.readResponse(args); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + process.close(); + if (attemptTimer != null) + attemptTimer.cancel(); + } + } + + @Override + public void startup() { + ; + } + + @Override + public boolean isRunning() { + return false; + } + + @Override + public void shutdown() { + ; + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/Tag.java b/src/main/java/com/thebuzzmedia/exiftool/Tag.java new file mode 100644 index 00000000..38db36ab --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/Tag.java @@ -0,0 +1,423 @@ +package com.thebuzzmedia.exiftool; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; + +// ================================================================================ +/** + * Enum used to pre-define a convenient list of tags that can be easily extracted from images using this class with an + * external install of ExifToolNew3. + *

+ * Each tag defined also includes a type hint for the parsed value associated with it when the default + * {@link Format#NUMERIC} value format is used. + *

+ * All replies from ExifToolNew3 are parsed as {@link String}s and using the type hint from each {@link Tag} can easily + * be converted to the correct data format by using the provided {@link Tag#parseValue(String)} method. + *

+ * This class does not make an attempt at converting the value automatically in case the caller decides they would + * prefer tag values returned in {@link Format#HUMAN_READABLE} format and to avoid any compatibility issues with future + * versions of ExifToolNew3 if a tag's return value is changed. This approach to leaving returned tag values as strings + * until the caller decides they want to parse them is a safer and more robust approach. + *

+ * The types provided by each tag are merely a hint based on the ExifToolNew3 Tag Guide by Phil Harvey; + * the caller is free to parse or process the returned {@link String} values any way they wish. + *

Tag Support

+ * ExifToolNew3 is capable of parsing almost every tag known to man (1000+), but this class makes an attempt at + * pre-defining a convenient list of the most common tags for use. + *

+ * This list was determined by looking at the common metadata tag values written to images by popular mobile devices + * (iPhone, Android) as well as cameras like simple point and shoots as well as DSLRs. As an additional source of input + * the list of supported/common EXIF formats that Flickr supports was also reviewed to ensure the most common/useful + * tags were being covered here. + *

+ * Please email me or file an issue if you think this list + * is missing a commonly used tag that should be added to it. + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +public enum Tag implements MetadataTag { + // single entry tags + APERTURE("ApertureValue", Double.class), + ARTIST("Artist", String.class), + AUTHOR("XPAuthor", String.class), + AVG_BITRATE("AvgBitrate", String.class), + BODY_SERIAL_NUMBER("BodySerialNumber", String.class), + CAMERA_SERIAL_NUMBER("CameraSerialNumber", String.class), + CAPTION_ABSTRACT("Caption-Abstract", String.class), + COLOR_SPACE("ColorSpace", Integer.class), + COMMENT("XPComment", String.class), + COMMENTS("Comment", String.class), + CONTENT_CREATION_DATE("ContentCreateDate", Date.class), + CONTRAST("Contrast", Integer.class), + COPYRIGHT("Copyright", String.class), + COPYRIGHT_NOTICE("CopyrightNotice", String.class), + CREATE_DATE("CreateDate", Date.class), + CREATION_DATE("CreationDate", Date.class), + CREATOR("Creator", String.class), + DATE_CREATED("DateCreated", Date.class), + DATE_TIME_ORIGINAL("DateTimeOriginal", Date.class), + DEVICE_SERIAL_NUMBER("DeviceSerialNumber", String.class), + DIGITAL_ZOOM_RATIO("DigitalZoomRatio", Double.class), + EXIF_VERSION("ExifVersion", String.class), + EXPOSURE_COMPENSATION("ExposureCompensation", Double.class), + EXPOSURE_PROGRAM("ExposureProgram", Integer.class), + EXPOSURE_TIME("ExposureTime", Double.class), + EXTENDER_SERIAL_NUMBER("ExtenderSerialNumber", String.class), + FILE_TYPE("FileType", String.class), + FLASH("Flash", Integer.class), + FLASH_SERIAL_NUMBER("FlashSerialNumber", String.class), + FOCAL_LENGTH("FocalLength", Double.class), + FOCAL_LENGTH_35MM("FocalLengthIn35mmFormat", Integer.class), + FNUMBER("FNumber", String.class), + GPS_ALTITUDE("GPSAltitude", Double.class), + GPS_ALTITUDE_REF("GPSAltitudeRef", Integer.class), + GPS_BEARING("GPSDestBearing", Double.class), + GPS_BEARING_REF("GPSDestBearingRef", String.class), + GPS_DATESTAMP("GPSDateStamp", String.class), + GPS_LATITUDE("GPSLatitude", Double.class), + GPS_LATITUDE_REF("GPSLatitudeRef", String.class), + GPS_LONGITUDE("GPSLongitude", Double.class), + GPS_LONGITUDE_REF("GPSLongitudeRef", String.class), + GPS_PROCESS_METHOD("GPSProcessingMethod", String.class), + GPS_SPEED("GPSSpeed", Double.class), + GPS_SPEED_REF("GPSSpeedRef", String.class), + GPS_TIMESTAMP("GPSTimeStamp", String.class), + IMAGE_HEIGHT("ImageHeight", Integer.class), + IMAGE_UNIQUE_ID("ImageUniqueID", String.class), + IMAGE_WIDTH("ImageWidth", Integer.class), + INTERNAL_SERIAL_NUMBER("InternalSerialNumber", String.class), + IPTC_KEYWORDS("Keywords", String.class), + ISO("ISO", Integer.class), + KEYWORDS("XPKeywords", String.class), + LENS("Lens", String.class), + LENS_ID("LensID", String.class), + LENS_MAKE("LensMake", String.class), + LENS_MODEL("LensModel", String.class), + LENS_SERIAL_NUMBER("LensSerialNumber", String.class), + MAKE("Make", String.class), + METERING_MODE("MeteringMode", Integer.class), + MIME_TYPE("MIMEType", String.class), + MODEL("Model", String.class), + OBJECT_NAME("ObjectName", String.class), + ORIENTATION("Orientation", Integer.class), + OWNER_NAME("OwnerName", String.class), + RATING("Rating", Integer.class), + RATING_PERCENT("RatingPercent", Integer.class), + ROTATION("Rotation", Integer.class), + SATURATION("Saturation", Integer.class), + SCANNER_SERIAL_NUMBER("ScannerSerialNumber", String.class), + SENSING_METHOD("SensingMethod", Integer.class), + SERIAL_NUMBER("SerialNumber", String.class), + SHARPNESS("Sharpness", Integer.class), + SHUTTER_SPEED("ShutterSpeedValue", Double.class), + SOFTWARE("Software", String.class), + SOURCE_SERIAL_NUMBER("SourceSerialNumber", String.class), + SUB_SEC_TIME_ORIGINAL("SubSecTimeOriginal", Integer.class), + SUB_SEC_DATE_TIME_ORIGINAL("SubSecDateTimeOriginal", Date.class), + SUBJECT("XPSubject", String.class), + TITLE("XPTitle", String.class), + WHITE_BALANCE("WhiteBalance", Integer.class), + X_RESOLUTION("XResolution", Double.class), + Y_RESOLUTION("YResolution", Double.class), + // select ICC metadata + ICC_DESCRIPTION("ProfileDescription", String.class), + ICC_COLORSPACEDATA("ColorSpaceData", String.class), + // actually binary data, but what are we doing to do here??? Just use to + // save to file... + THUMBNAIL_IMAGE("ThumbnailImage", String.class), + THUMBNAIL_PHOTOSHOP("PhotoshopThumbnail", String.class), ; + + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(Tag.class); + private static final Map TAG_LOOKUP_MAP; + + /** + * Initializer used to init the static final tag/name lookup map used by all instances of this class. + */ + static { + Tag[] values = Tag.values(); + TAG_LOOKUP_MAP = new HashMap(values.length * 3); + + for (int i = 0; i < values.length; i++) { + Tag tag = values[i]; + TAG_LOOKUP_MAP.put(tag.getKey(), tag); + } + } + + /** + * Used to get the {@link Tag} identified by the given, case-sensitive, tag name. + * + * @param name + * The case-sensitive name of the tag that will be searched for. + * + * @return the {@link Tag} identified by the given, case-sensitive, tag name or null if one couldn't be + * found. + */ + public static Tag forName(String name) { + return TAG_LOOKUP_MAP.get(name); + } + + public static Map toTagMap(Map values) { + return mapByTag(values); + } + + private static Map mapByTag(Map stringMap) { + Map tagMap = new HashMap(Tag.values().length); + for (Tag tag : Tag.values()) { + if (stringMap.containsKey(tag.getKey())) { + tagMap.put(tag, stringMap.get(tag.getKey())); + } + } + return tagMap; + } + + /** + * Convenience method used to convert the given string Tag value (returned from the external ExifToolNew3 process) + * into the type described by the associated {@link Tag}. + * + * @param + * The type of the returned value. + * @param value + * The {@link String} representation of the tag's value as parsed from the image. + * + * @return the given string value converted to a native Java type (e.g. Integer, Double, etc.). + * + * @throws IllegalArgumentException + * if tag is null. + * @throws NumberFormatException + * if any exception occurs while trying to parse the given value to any of the supported + * numeric types in Java via calls to the respective parseXXX methods defined on all the + * numeric wrapper classes (e.g. {@link Integer#parseInt(String)} , {@link Double#parseDouble(String)} + * and so on). + * @throws ClassCastException + * if the type defined by T is incompatible with the type defined by {@link Tag#getType()} + * returned by the tag argument passed in. This class performs an implicit/unchecked cast + * to the type T before returning the parsed result of the type indicated by + * {@link Tag#getType()}. If the types do not match, a ClassCastException will be generated + * by the VM. + */ + @SuppressWarnings("unchecked") + public T parseValue(String value) throws IllegalArgumentException { + return parseValue((Class) getType(), value); + } + + public String getRawValue(Map metadata) { + return metadata.get(this); + } + + @SuppressWarnings("unchecked") + public V2 getValue(Map metadata) { + return (V2) parseValue(this, metadata.get(this)); + } + + @SuppressWarnings("unchecked") + public static T parseValue(MetadataTag tag, Object value) throws IllegalArgumentException, + NumberFormatException { + if (tag == null) + throw new IllegalArgumentException("tag cannot be null"); + Class type = tag.getType(); + return parseValue(type, value); + } + + @SuppressWarnings("unchecked") + public static String toExif(MetadataTag tag, Object value) throws IllegalArgumentException, + NumberFormatException { + String result = null; + if (value == null) { + // nothing to do + } else { + Class type = tag.getType(); + if (Date.class.equals(type)) { + if (!Date.class.equals(value.getClass())) { + value = parseValue(tag, value); + } + SimpleDateFormat formatter = new SimpleDateFormat(ExifToolNew.EXIF_DATE_FORMAT); + try { + result = formatter.format(value); + } catch (IllegalArgumentException e) { + throw new ExifError("Cannot convert [" + value + "] of type " + value.getClass() + + " to date using formatter [" + ExifToolNew.EXIF_DATE_FORMAT + "]", e); + } + } else if (String[].class.equals(type)) { + // @see http://www.sno.phy.queensu.ca/~phil/exiftool/faq.html - 17. + // "List-type tags do not behave as expected" + // result = Joiner.on(", ").join((String[])value); + result = value.toString(); + } else { + result = value.toString(); + } + } + return result; + } + + @SuppressWarnings("unchecked") + public static T parseValue(Class type, Object value) throws IllegalArgumentException, NumberFormatException { + T result = null; + if (value == null) { + // nothing to do + } else if (type == value.getClass()) { + result = (T) value; + } else if (Boolean.class.isAssignableFrom(type)) + result = (T) Boolean.valueOf(value.toString()); + else if (Byte.class.isAssignableFrom(type)) + result = (T) Byte.valueOf(Byte.parseByte(value.toString())); + else if (Integer.class.isAssignableFrom(type)) + result = (T) Integer.valueOf(Integer.parseInt(value.toString())); + else if (Short.class.isAssignableFrom(type)) + result = (T) Short.valueOf(Short.parseShort(value.toString())); + else if (Long.class.isAssignableFrom(type)) + result = (T) Long.valueOf(Long.parseLong(value.toString())); + else if (Float.class.isAssignableFrom(type)) + result = (T) new Float(parseDouble(value.toString()).floatValue()); + else if (Double.class.isAssignableFrom(type)) + result = (T) parseDouble(value.toString()); + else if (Character.class.isAssignableFrom(type)) + result = (T) Character.valueOf(value.toString().charAt(0)); + else if (String.class.isAssignableFrom(type)) + result = (T) value.toString(); + else if (String[].class.equals(type)) { + // @see http://www.sno.phy.queensu.ca/~phil/exiftool/faq.html - 17. + // "List-type tags do not behave as expected" + result = (T) Splitter.on(", ").splitToList(value.toString()).toArray(new String[0]); + } else if (Date.class.equals(type)) { + SimpleDateFormat formatter = new SimpleDateFormat(ExifToolNew.EXIF_DATE_FORMAT); + try { + result = (T) formatter.parse(value.toString()); + } catch (ParseException e) { + try { + long value2 = Long.parseLong(value.toString()); + result = (T)new Date(value2); + } catch (NumberFormatException e2) { + throw new ExifError("Can't parse value " + value + " with format [" + + ExifToolNew.EXIF_DATE_FORMAT + "] and neither as a number of miliseconds from ["+new Date(0)+"].", e); + } + } + } else + result = (T) value; + return result; + } + + private static Double parseDouble(String in) { + if (in.contains("/")) { + String[] enumeratorAndDivisor = in.split("/"); + return Double.parseDouble(enumeratorAndDivisor[0]) / Double.parseDouble(enumeratorAndDivisor[1]); + } else { + return Double.parseDouble(in); + } + } + + @SuppressWarnings("unchecked") + public T parseValue2(String value) throws IllegalArgumentException { + return (T) deserialize(getKey(), value, getType()); + } + + public static Object deserialize(String tagName, String value, Class expectedType) { + try { + if (Boolean.class.equals(expectedType)) { + if (value == null) + return null; + value = value.trim().toLowerCase(); + switch (value.charAt(0)) { + case 'n': + case 'f': + case '0': + return false; + } + if (value.equals("off")) { + return false; + } + return true; + } else if (Date.class.equals(expectedType)) { + if (value == null) + return null; + SimpleDateFormat formatter = new SimpleDateFormat(ExifToolNew.EXIF_DATE_FORMAT); + return formatter.parse(value); + } else if (Integer.class.equals(expectedType)) { + if (value == null) + return 0; + return Integer.parseInt(value); + } else if (Long.class.equals(expectedType)) { + if (value == null) + return 0; + return Long.parseLong(value); + } else if (Float.class.equals(expectedType)) { + if (value == null) + return 0; + return Float.parseFloat(value); + } else if (Double.class.equals(expectedType)) { + if (value == null) + return 0; + String[] enumeratorAndDivisor = value.split("/"); + if (enumeratorAndDivisor.length == 2) { + return Double.parseDouble(enumeratorAndDivisor[0]) / Double.parseDouble(enumeratorAndDivisor[1]); + } else { + return Double.parseDouble(value); + } + } else if (String[].class.equals(expectedType)) { + if (value == null) + return new String[0]; + return value.split(","); + } else { + return value; + } + } catch (ParseException ex) { + LOG.warn("Invalid format, Tag:" + tagName); + return null; + } catch (NumberFormatException ex) { + LOG.warn("Invalid format, Tag:" + tagName); + return null; + } + + } + + @Deprecated + public String getName() { + return getKey(); + } + + /** + * Used to get a hint for the native type of this tag's value as specified by Phil Harvey's ExifToolNew3 Tag Guide. + * + * @return a hint for the native type of this tag's value. + */ + @Override + public Class getType() { + return type; + } + + /** + * Used to get the name of the tag (e.g. "Orientation", "ISO", etc.). + * + * @return the name of the tag (e.g. "Orientation", "ISO", etc.). + */ + @Override + public String getKey() { + return key; + } + + @Override + public boolean isMapped() { + return true; + } + + private String key; + private Class type; + + private Tag(String key, Class type) { + this.key = key; + this.type = type; + } + + @Override + public String toExif(T value) { + return toExif(this, value); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/TagGroup.java b/src/main/java/com/thebuzzmedia/exiftool/TagGroup.java new file mode 100644 index 00000000..190065ec --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/TagGroup.java @@ -0,0 +1,47 @@ +package com.thebuzzmedia.exiftool; + +// ================================================================================ +public enum TagGroup implements MetadataTag { + EXIF("EXIF", "exif:all"), + IPTC("IPTC", "iptc:all"), + XMP("XMP", "xmp:all"), + ALL("ALL", "all"), + FILE("FILE", "file:all"), + ICC("ICC", "icc_profile:all"); + + private final String name; + private final String key; + + private TagGroup(String name, String key) { + this.name = name; + this.key = key; + } + + public String getName() { + return name; + } + @Deprecated + public String getValue() { + return getKey(); + } + + @Override + public String getKey() { + return key; + } + + @Override + public Class getType() { + return Void.class; + } + + @Override + public boolean isMapped() { + return false; + } + + @Override + public String toExif(T value) { + return Tag.toExif(this, value); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/UnsupportedFeatureException.java b/src/main/java/com/thebuzzmedia/exiftool/UnsupportedFeatureException.java new file mode 100644 index 00000000..2089a8aa --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/UnsupportedFeatureException.java @@ -0,0 +1,28 @@ +package com.thebuzzmedia.exiftool; + +/** + * Class used to define an exception that occurs when the caller attempts to use + * a {@link Feature} that the underlying native ExifToolNew3 install does not + * support (i.e. the version isn't new enough). + * + * @author Riyad Kalla (software@thebuzzmedia.com) + * @since 1.1 + */ +public class UnsupportedFeatureException extends RuntimeException { + private static final long serialVersionUID = -1332725983656030770L; + + private Feature feature; + + public UnsupportedFeatureException(Feature feature) { + super( + "Use of feature [" + + feature + + "] requires version " + + feature.getVersion() + + " or higher of the native ExifToolNew3 program. The version of ExifToolNew3 referenced by the system property 'exiftool.path' is not high enough. You can either upgrade the install of ExifToolNew3 or avoid using this feature to workaround this exception."); + } + + public Feature getFeature() { + return feature; + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/VersionNumber.java b/src/main/java/com/thebuzzmedia/exiftool/VersionNumber.java new file mode 100644 index 00000000..ede4b748 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/VersionNumber.java @@ -0,0 +1,49 @@ +package com.thebuzzmedia.exiftool; + +// ================================================================================ +/** + * Version Number used to determine if one version is after another. + * + * @author Matt Gile, msgile + */ +class VersionNumber { + private final int[] numbers; + + public VersionNumber(String str) { + String[] versionParts = str.trim().split("\\."); + this.numbers = new int[versionParts.length]; + for (int i = 0; i < versionParts.length; i++) { + numbers[i] = Integer.parseInt(versionParts[i]); + } + } + + public VersionNumber(int... numbers) { + this.numbers = numbers; + } + + public boolean isBeforeOrEqualTo(VersionNumber other) { + int max = Math.min(this.numbers.length, other.numbers.length); + for (int i = 0; i < max; i++) { + if (this.numbers[i] > other.numbers[i]) { + return false; + } else if (this.numbers[i] < other.numbers[i]) { + return true; + } + } + // assume missing number as zero, so if the current process number + // is more digits it is a higher version + return this.numbers.length <= other.numbers.length; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + for (int number : numbers) { + if (builder.length() > 0) { + builder.append("."); + } + builder.append(number); + } + return builder.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/WriteOptions.java b/src/main/java/com/thebuzzmedia/exiftool/WriteOptions.java new file mode 100644 index 00000000..d3b5dca5 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/WriteOptions.java @@ -0,0 +1,43 @@ +package com.thebuzzmedia.exiftool; + +// ================================================================================ +/** + * All the write options, is immutable, copy on change, fluent style "with" + * setters. + */ +public class WriteOptions { + final long runTimeoutMills; + final boolean deleteBackupFile; + final boolean ignoreMinorErrors; + + public WriteOptions() { + this(0, false, false); + } + + private WriteOptions(long runTimeoutMills, boolean deleteBackupFile, boolean ignoreMinorErrors) { + this.runTimeoutMills = runTimeoutMills; + this.deleteBackupFile = deleteBackupFile; + this.ignoreMinorErrors = ignoreMinorErrors; + } + + @Override + public String toString() { + return String.format("%s(runTimeOut:%,d deleteBackupFile:%s ignoreMinorErrors:%s)",getClass().getSimpleName(),runTimeoutMills,deleteBackupFile,ignoreMinorErrors); + } + + public WriteOptions withRunTimeoutMills(long mills) { + return new WriteOptions(mills, deleteBackupFile, ignoreMinorErrors); + } + + /** + * ExifToolNew3 automatically makes a backup copy a file before writing metadata + * tags in the form "file.ext_original", by default this tool will delete + * that original file after the writing is done. + */ + public WriteOptions withDeleteBackupFile(boolean enabled) { + return new WriteOptions(runTimeoutMills, enabled, ignoreMinorErrors); + } + public WriteOptions withIgnoreMinorErrors(boolean enabled) { + return new WriteOptions(runTimeoutMills,deleteBackupFile, enabled); + } +} \ No newline at end of file diff --git a/src/main/java/com/thebuzzmedia/exiftool/adapters/BaseRawExifTool.java b/src/main/java/com/thebuzzmedia/exiftool/adapters/BaseRawExifTool.java new file mode 100644 index 00000000..9b14c951 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/adapters/BaseRawExifTool.java @@ -0,0 +1,89 @@ +package com.thebuzzmedia.exiftool.adapters; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import com.thebuzzmedia.exiftool.*; + +public class BaseRawExifTool implements RawExifTool { + + @Override + public boolean isFeatureSupported(Feature feature) throws RuntimeException { + // TODO Auto-generated method stub + return false; + } + +// @Override +// public void startup() { +// // TODO Auto-generated method stub +// +// } + + @Override + public void shutdown() { + // TODO Auto-generated method stub + + } + + @Override + public void close() { + // TODO Auto-generated method stub + + } + + @Override + public boolean isStayOpen() { + // TODO Auto-generated method stub + return false; + } + + @Override + public boolean isRunning() { + // TODO Auto-generated method stub + return false; + } + + @Override + public boolean isFeatureEnabled(Feature feature) throws IllegalArgumentException { + // TODO Auto-generated method stub + return false; + } + + @Override + public void addImageMetadata(File file, Map values) throws IOException { + // TODO Auto-generated method stub + + } + + @Override + public void writeMetadata(WriteOptions options, File file, Map values) throws IOException { + // TODO Auto-generated method stub + + } + + @Override + public void rebuildMetadata(File file) throws IOException { + // TODO Auto-generated method stub + + } + + @Override + public void rebuildMetadata(WriteOptions options, File file) throws IOException { + // TODO Auto-generated method stub + + } + + @Override + public Map getImageMeta(File file, ReadOptions readOptions, String... tags) throws IOException { + // TODO Auto-generated method stub + return null; + } + + @Override + public List execute(List args) { + // TODO Auto-generated method stub + return null; + } +} diff --git a/src/main/java/com/thebuzzmedia/exiftool/adapters/ExifToolService.java b/src/main/java/com/thebuzzmedia/exiftool/adapters/ExifToolService.java new file mode 100644 index 00000000..f79351d5 --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/adapters/ExifToolService.java @@ -0,0 +1,382 @@ +package com.thebuzzmedia.exiftool.adapters; + +import java.io.*; +import java.util.*; +import java.util.Map.Entry; +import java.util.regex.Pattern; + +import org.apache.commons.io.FilenameUtils; + +import com.google.common.base.Function; +import com.google.common.base.Joiner; +import com.google.common.collect.Lists; +import com.thebuzzmedia.exiftool.*; + +@SuppressWarnings("deprecation") +public class ExifToolService extends RawExifToolAdapter implements Closeable { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(ExifToolService.class); + + public ExifToolService(RawExifTool exifTool) { + super(exifTool); + } + + // public Map getImageMeta(File image, Tag... tags) throws IllegalArgumentException, + // SecurityException, IOException { + // return getImageMeta(image, Format.NUMERIC, tags); + // } + public Map getImageMeta2(File image, ReadOptions options, MetadataTag... tags) + throws IllegalArgumentException, SecurityException, IOException { + Map all = getImageMeta(image, options, toKeys(tags)); + return (Map) toMetadataTagKeys(all, tags); + } + + // + // public Map getImageMeta2c(File file, ReadOptions options, MetadataTag... tags) + // throws IllegalArgumentException, SecurityException, IOException { + // return getImageMeta6(file, options, tags); + // } + // + // public Map getImageMeta2b(File image, ReadOptions options, MetadataTag... tags) + // throws IllegalArgumentException, SecurityException, IOException { + // return (Map) getImageMeta3(image, options, tags); + // } + + public Map getImageMeta3(File image, ReadOptions options, MetadataTag... tags) + throws IllegalArgumentException, SecurityException, IOException { + return getImageMeta4d(image, new ReadOptions().withNumericOutput(Format.NUMERIC), tags); + } + + public Map getImageMeta4d(File image, ReadOptions options, MetadataTag... tags) + throws IllegalArgumentException, SecurityException, IOException { + return toMetadataTagKeys(getImageMeta(image, options, toKeys(tags)), tags); + } + + // + // public Map getImageMeta4c(File file, ReadOptions options, Format format, MetadataTag... + // tags) + // throws IllegalArgumentException, SecurityException, IOException { + // Map result = getImageMeta6(file, options, tags); + // // since meta tags are passed we will have a proper Map result + // return (Map) result; + // } + // + // public Map getImageMeta4b(File image, ReadOptions options, Format format, MetadataTag... + // tags) + // throws IllegalArgumentException, SecurityException, IOException { + // if (tags == null) { + // tags = new MetadataTag[0]; + // } + // String[] stringTags = new String[tags.length]; + // int i = 0; + // for (MetadataTag tag : tags) { + // stringTags[i++] = tag.getKey(); + // } + // Map result = exifTool.getImageMeta(image, new ReadOptions().withNumericOutput(format) + // .withShowDuplicates(!true), stringTags); + // ReadOptions readOptions = new ReadOptions().withConvertTypes(true).withNumericOutput( + // format.equals(Format.NUMERIC)); + // return (Map) convertToMetadataTags(readOptions, result, tags); + // // map only known values? + // // return Tag.toTagMap(result); + // } + + public Map getImageMeta5(File image, ReadOptions options, Format format, TagGroup... tags) + throws IllegalArgumentException, SecurityException, IOException { + if (tags == null) { + tags = new TagGroup[0]; + } + String[] stringTags = new String[tags.length]; + int i = 0; + for (TagGroup tag : tags) { + stringTags[i++] = tag.getValue(); + } + return exifTool.getImageMeta(image, new ReadOptions().withNumericOutput(format).withShowDuplicates(true), + stringTags); + } + + // + // @Override + // public Map getImageMeta(File file, ReadOptions options, Format format, boolean supressDuplicates, + // String... tags) + // throws IOException { + // ReadOptions options = defReadOptions.withNumericOutput(format == Format.NUMERIC) + // .withShowDuplicates(!supressDuplicates).withConvertTypes(false); + // Map result = getImageMeta7(file, options, tags); + // Map data = new TreeMap(); + // for (Map.Entry entry : result.entrySet()) { + // data.put(entry.getKey().toString(), entry.getValue() != null ? entry.getValue().toString() : ""); + // } + // return data; + // } + + // public Map getImageMeta(File image, Format format, Tag... tags) + // throws IllegalArgumentException, SecurityException, IOException { + // if (tags == null) { + // tags = new Tag[0]; + // } + // String[] stringTags = new String[tags.length]; + // int i = 0; + // for (Tag tag : tags) { + // stringTags[i++] = tag.getKey(); + // } + // Map result = getImageMeta(image, format, true, stringTags); + // return Tag.toTagMap(result); + // } + + // @Override + // public Map getImageMeta5b(File image, ReadOptions options, Format format, TagGroup... tags) + // throws IllegalArgumentException, SecurityException, IOException { + // if (tags == null) { + // tags = new TagGroup[0]; + // } + // String[] stringTags = new String[tags.length]; + // int i = 0; + // for (TagGroup tag : tags) { + // stringTags[i++] = tag.getKey(); + // } + // return exifTool.getImageMeta(image, options.withNumericOutput(format).withShowDuplicates(false), stringTags); + // } + + public Map getImageMeta6(File file, ReadOptions options, Object... tags) throws IOException { + return getImageMeta7(file, options, tags); + } + + public Map getImageMeta7(File file, ReadOptions options, Object... tags) throws IOException { + if (tags == null) { + tags = new TagGroup[0]; + } + Map resultMap = exifTool.getImageMeta(file, options, toKeys(tags)); + return convertToMetadataTags(options, resultMap, tags); + } + + /** + * extract image metadata to exiftool's internal xml format. + * + * @param input + * the input file + * @return command output as xml string + * @throws IOException + * Signals that an I/O exception has occurred. + */ + public String getImageMetadataXml(File input, boolean includeBinary) throws IOException { + List args = new ArrayList(); + args.add("-X"); + if (includeBinary) + args.add("-b"); + args.add(input.getAbsolutePath()); + + return toResponse(exifTool.execute(args)); + } + + /** + * extract image metadata to exiftool's internal xml format. + * + * @param input + * the input file + * @param output + * the output file + * @throws IOException + * Signals that an I/O exception has occurred. + */ + public void getImageMetadataXml(File input, File output, boolean includeBinary) throws IOException { + + String result = getImageMetadataXml(input, includeBinary); + + try (FileWriter w = new FileWriter(output)) { + w.write(result); + } + } + + /** + * output icc profile from input to output. + * + * @param input + * the input file + * @param output + * the output file for icc data + * @return the command result from standard output e.g. "1 output files created" + * @throws IOException + * Signals that an I/O exception has occurred. + */ + public String extractImageIccProfile(File input, File output) throws IOException { + + List args = new ArrayList(); + args.add("-icc_profile"); + args.add(input.getAbsolutePath()); + + args.add("-o"); + args.add(output.getAbsolutePath()); + return toResponse(exifTool.execute(args)); + } + + /** + * Extract thumbnail from the given tag. + * + * @param input + * the input file + * @param tag + * the tag containing binary data PhotoshopThumbnail or ThumbnailImage + * @return the thumbnail file created. it is in the same folder as the input file because of the syntax of exiftool + * and has the suffix ".thumb.jpg" + * @throws IOException + * Signals that an I/O exception has occurred. + */ + public File extractThumbnail(File input, Tag tag) throws IOException { + + List args = new ArrayList(); + String suffix = ".thumb.jpg"; + String thumbname = FilenameUtils.getBaseName(input.getName()) + suffix; + + args.add("-" + tag.getKey()); + args.add(input.getAbsolutePath()); + args.add("-b"); + args.add("-w"); + args.add(suffix); + String result = toResponse(exifTool.execute(args)); + File thumbnail = new File(input.getParent() + File.separator + thumbname); + if (!thumbnail.exists()) + throw new IOException("could not create thumbnail: " + result); + return thumbnail; + } + + private String[] toKeys(Object... tags) { + return Lists.transform(Arrays.asList(tags), new Function() { + @Override + public String apply(Object tag) { + if (tag instanceof MetadataTag) { + return ((MetadataTag) tag).getKey(); + } else { + return tag.toString(); + } + } + }).toArray(new String[0]); + } + + public static Map convertToMetadataTags(ReadOptions options, Map resultMap, + Object... tags) { + Map metadata = new HashMap(resultMap.size()); + + for (Object tag : tags) { + MetadataTag metaTag; + if (tag instanceof MetadataTag) { + metaTag = (MetadataTag) tag; + } else { + metaTag = toTag(tag.toString()); + } + if (metaTag.isMapped()) { + String input = resultMap.remove(metaTag.getKey()); + if (!options.showEmptyTags && (input == null || input.isEmpty())) { + continue; + } + Object value = options.convertTypes ? Tag.deserialize(metaTag.getKey(), input, metaTag.getType()) + : input; + // maps with tag passed in, as caller expects to fetch + metadata.put(metaTag, value); + } + } + for (Map.Entry entry : resultMap.entrySet()) { + if (!options.showEmptyTags && entry.getValue() == null || entry.getValue().isEmpty()) { + continue; + } + if (options.convertTypes) { + MetadataTag metaTag = toTag(entry.getKey()); + Object value = Tag.deserialize(metaTag.getKey(), entry.getValue(), metaTag.getType()); + // metadata.put(entry.getKey(), value); + metadata.put(metaTag, value); + } else { + metadata.put(entry.getKey(), entry.getValue()); + + } + } + return metadata; + } + + static MetadataTag toTag(String name) { + // Tag.forName( + for (Tag tag : Tag.values()) { + if (tag.getKey().equalsIgnoreCase(name)) { + return tag; + } + } + for (MwgTag tag : MwgTag.values()) { + if (tag.getKey().equalsIgnoreCase(name)) { + return tag; + } + } + return new CustomTag(name, String.class); + } + + private Map toMetadataTagKeys(Map all, MetadataTag... tags) { + Map result = new HashMap(); + if (tags == null | tags.length == 0) { + for (Entry entry : all.entrySet()) { + MetadataTag tag = toTag(entry.getKey()); + // if (tag != null) { + result.put(tag, entry.getValue()); + // } + } + } else { + for (MetadataTag tag : tags) { + String value = all.get(tag.getKey()); + if (value != null) { + result.put(tag, value); + } + } + } + return result; + } + + private String[] toKeys(MetadataTag... tags) { + return Lists.transform(Arrays.asList(tags), new Function() { + @Override + public String apply(MetadataTag input) { + return input.getKey(); + } + }).toArray(new String[0]); + } + + // + // @Override @Deprecated + // public Map getImageMeta(File file, Format format, + // boolean supressDuplicates, String... tags) throws IOException { + // return getImageMeta(file,format.withSuppressDuplicates(),tags); + // } + // + // @Override @Deprecated + // public Map getImageMeta(File file, ReadOptions options, + // boolean supressDuplicates, String... tags) throws IOException { + // return getImageMeta10(file,options,tags); + // } + + @Override + public void close() { + super.close(); + } + @Override + protected void finalize() throws Throwable { + close(); + } + + /** + * Compiled {@link Pattern} of ": " used to split compact output from ExifToolNew3 evenly into name/value pairs. + */ + private static final Pattern TAG_VALUE_PATTERN = Pattern.compile("\\s*:\\s*"); + + public static String toResponse(List results) { + return Joiner.on('\n').join(results); + } + + public static Map toMap(List all) { + Map resultMap = new HashMap(500); + for (String line : all) { + String[] pair = TAG_VALUE_PATTERN.split(line, 2); + if (pair.length == 2) { + resultMap.put(pair[0], pair[1]); + LOG.debug(String.format("\tRead Tag [name=%s, value=%s]", pair[0], pair[1])); + } else { + LOG.info(String.format("\tIgnore line [%s]", line)); + } + } + return resultMap; + } +} diff --git a/src/main/java/com/thebuzzmedia/exiftool/adapters/RawExifToolAdapter.java b/src/main/java/com/thebuzzmedia/exiftool/adapters/RawExifToolAdapter.java new file mode 100644 index 00000000..e5ad629f --- /dev/null +++ b/src/main/java/com/thebuzzmedia/exiftool/adapters/RawExifToolAdapter.java @@ -0,0 +1,81 @@ +package com.thebuzzmedia.exiftool.adapters; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import com.thebuzzmedia.exiftool.*; + +public abstract class RawExifToolAdapter implements RawExifTool{ + protected RawExifTool exifTool; + + public RawExifToolAdapter(RawExifTool exifTool) { + this.exifTool = exifTool; + } + + @Override + public boolean isFeatureSupported(Feature feature) throws RuntimeException { + return exifTool.isFeatureSupported(feature); + } + +// @Override +// public void startup() { +// exifTool.startup(); +// } + + @Override + public void shutdown() { + exifTool.shutdown(); + } + + @Override + public void close() { + exifTool.close(); + } + + @Override + public boolean isStayOpen() { + return exifTool.isStayOpen(); + } + + @Override + public boolean isRunning() { + return exifTool.isRunning(); + } + + @Override + public boolean isFeatureEnabled(Feature feature) throws IllegalArgumentException { + return exifTool.isFeatureEnabled(feature); + } + + @Override + public void addImageMetadata(File file, Map values) throws IOException { + exifTool.addImageMetadata(file, values); + } + + @Override + public void writeMetadata(WriteOptions options, File file, Map values) throws IOException { + exifTool.writeMetadata(options, file, values); + } + + @Override + public void rebuildMetadata(File file) throws IOException { + exifTool.rebuildMetadata(file); + } + + @Override + public void rebuildMetadata(WriteOptions options, File file) throws IOException { + exifTool.rebuildMetadata(options, file); + } + + @Override + public Map getImageMeta(File file, ReadOptions readOptions, String... tags) throws IOException { + return exifTool.getImageMeta(file, readOptions, tags); + } + + @Override + public List execute(List args) { + return exifTool.execute(args); + } +} diff --git a/src/main/main.iml b/src/main/main.iml deleted file mode 100644 index 3e356326..00000000 --- a/src/main/main.iml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/test/java/com/thebuzzmedia/exiftool/Benchmark.java b/src/test/java/com/thebuzzmedia/exiftool/Benchmark.java index 6145f0a7..9362d265 100644 --- a/src/test/java/com/thebuzzmedia/exiftool/Benchmark.java +++ b/src/test/java/com/thebuzzmedia/exiftool/Benchmark.java @@ -1,76 +1,75 @@ package com.thebuzzmedia.exiftool; -import com.thebuzzmedia.exiftool.ExifTool.Feature; -import com.thebuzzmedia.exiftool.ExifTool.Tag; +import java.io.*; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; -import java.io.IOException; +import com.thebuzzmedia.exiftool.adapters.ExifToolService; public class Benchmark { + public static final int ITERATIONS = 25; + private static Logger log = LoggerFactory.getLogger(Benchmark.class); - public static final int ITERATIONS = 25; - private static Logger log = LoggerFactory.getLogger(Benchmark.class); - - - public static void main(String[] args) throws IOException, InterruptedException { + public static void main(String[] args) throws IOException, + InterruptedException { - //System.setProperty(ExifTool.ENV_EXIF_TOOL_PATH, "D:\\Tools\\exiftool.exe"); + // System.setProperty(ExifToolNew3.ENV_EXIF_TOOL_PATH, + // "D:\\Tools\\exiftool.exe"); - final Tag[] tags = Tag.values(); - final File[] images = new File("src/test/resources").listFiles(); + final Tag[] tags = Tag.values(); + final File[] images = new File("src/test/resources").listFiles(); - log.info("Benchmark [tags=" + tags.length + ", images=" - + images.length + ", iterations=" + ITERATIONS + "]"); - log.info("\t" + (images.length * ITERATIONS) - + " ExifTool process calls, " - + (tags.length * images.length * ITERATIONS) - + " total operations.\n"); + log.info("Benchmark [tags=" + tags.length + ", images=" + images.length + + ", iterations=" + ITERATIONS + "]"); + log.info("\t" + (images.length * ITERATIONS) + + " ExifToolNew3 process calls, " + + (tags.length * images.length * ITERATIONS) + + " total operations.\n"); - ExifTool tool = new ExifTool(); - ExifTool toolSO = new ExifTool(Feature.STAY_OPEN); + ExifToolService tool = RawExifTool.Factory.create(); + ExifToolService toolSO = RawExifTool.Factory.create(Feature.STAY_OPEN); /* * -stay_open False */ - log.info("\t[-stay_open False]"); - long elapsedTime = 0; + log.info("\t[-stay_open False]"); + long elapsedTime = 0; - for (int i = 1; i <= ITERATIONS; i++){ - log.info(String.format("iteration %s of %s", i, ITERATIONS)); - elapsedTime += run(tool, images, tags); - } + for (int i = 1; i <= ITERATIONS; i++) { + log.info(String.format("iteration %s of %s", i, ITERATIONS)); + elapsedTime += run(tool, images, tags); + } - log.info("\t\tElapsed Time: " + elapsedTime + " ms (" - + ((double) elapsedTime / 1000) + " secs)"); + log.info("\t\tElapsed Time: " + elapsedTime + " ms (" + + ((double) elapsedTime / 1000) + " secs)"); /* * -stay_open True */ - log.info("\n\t[-stay_open True]"); - long elapsedTimeSO = 0; + log.info("\n\t[-stay_open True]"); + long elapsedTimeSO = 0; - for (int i = 1; i <= ITERATIONS; i++) { - log.info(String.format("iteration %s of %s", i, ITERATIONS)); - elapsedTimeSO += run(toolSO, images, tags); - } + for (int i = 1; i <= ITERATIONS; i++) { + log.info(String.format("iteration %s of %s", i, ITERATIONS)); + elapsedTimeSO += run(toolSO, images, tags); + } - log.info("\t\tElapsed Time: " + elapsedTimeSO + " ms (" - + ((double) elapsedTimeSO / 1000) + " secs - " - + ((float) elapsedTime / (float) elapsedTimeSO) + "x faster)"); + log.info("\t\tElapsed Time: " + elapsedTimeSO + " ms (" + + ((double) elapsedTimeSO / 1000) + " secs - " + + ((float) elapsedTime / (float) elapsedTimeSO) + "x faster)"); - // Shut down the running exiftool proc. - toolSO.close(); - } + // Shut down the running exiftool proc. + toolSO.close(); + } - private static long run(ExifTool tool, File[] images, Tag[] tags) - throws IllegalArgumentException, SecurityException, IOException { - long startTime = System.currentTimeMillis(); + private static long run(ExifToolService tool, File[] images, Tag[] tags) + throws IllegalArgumentException, SecurityException, IOException { + long startTime = System.currentTimeMillis(); - for (File image : images){ - tool.getImageMeta(image, tags); - } + for (File image : images) { + tool.getImageMeta3(image, new ReadOptions(), tags); + } - return (System.currentTimeMillis() - startTime); - } + return (System.currentTimeMillis() - startTime); + } } \ No newline at end of file diff --git a/src/test/java/com/thebuzzmedia/exiftool/Example.java b/src/test/java/com/thebuzzmedia/exiftool/Example.java index 27e536e7..d4c64990 100644 --- a/src/test/java/com/thebuzzmedia/exiftool/Example.java +++ b/src/test/java/com/thebuzzmedia/exiftool/Example.java @@ -1,46 +1,53 @@ package com.thebuzzmedia.exiftool; -import com.thebuzzmedia.exiftool.ExifTool.Feature; -import com.thebuzzmedia.exiftool.ExifTool.Format; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.File; import java.io.IOException; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.thebuzzmedia.exiftool.adapters.ExifToolService; + public class Example { - private static Logger log = LoggerFactory.getLogger(Example.class); - private static final String TEST_FILES_PATH = "src/test/resources"; - - public static void main(String[] args) throws IOException, InterruptedException { - - //System.setProperty(ExifTool.ENV_EXIF_TOOL_PATH, "D:\\Tools\\exiftool.exe"); - ExifTool tool = new ExifTool(Feature.STAY_OPEN); - - File[] images = new File(TEST_FILES_PATH).listFiles(); - - //list all first-class tags - for (File f : images) { - log.info("\n[{}]", f.getName()); - Map metadata = tool.getImageMeta(f, Format.HUMAN_READABLE, ExifTool.Tag.values()); - for (ExifTool.Tag key : metadata.keySet()){ - log.info(String.format("\t\t%s: %s", key.getKey(), metadata.get(key))); - } - } - - log.info("\n\n** GET TAGS BY GROUP"); - //list all XMP, IPTC and XMP tags - File f = new File(TEST_FILES_PATH + "/kureckjones_jett_075_02-cropped.tif"); - for (ExifTool.TagGroup tagGroup : new ExifTool.TagGroup[] {ExifTool.TagGroup.EXIF, ExifTool.TagGroup.IPTC, ExifTool.TagGroup.XMP}){ - Map metadata = tool.getImageMeta(f, Format.HUMAN_READABLE, tagGroup); - log.info(tagGroup.getKey()); - for (String key : metadata.keySet()){ - log.info(String.format("\t\t%s: %s", key, metadata.get(key))); - } - } - - tool.close(); - } + private static Logger log = LoggerFactory.getLogger(Example.class); + private static final String TEST_FILES_PATH = "src/test/resources"; + + public static void main(String[] args) throws IOException, + InterruptedException { + + // System.setProperty(ExifToolNew3.ENV_EXIF_TOOL_PATH, + // "D:\\Tools\\exiftool.exe"); + ExifToolService tool = RawExifTool.Factory.create(Feature.STAY_OPEN); + + File[] images = new File(TEST_FILES_PATH).listFiles(); + + // list all first-class tags + for (File f : images) { + log.info("\n[{}]", f.getName()); + Map metadata = tool.getImageMeta4d(f, new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), Tag.values()); + for (MetadataTag key : metadata.keySet()) { + log.info(String.format("\t\t%s: %s", key.getKey(), + metadata.get(key))); + } + } + + log.info("\n\n** GET TAGS BY GROUP"); + // list all XMP, IPTC and XMP tags + File f = new File(TEST_FILES_PATH + + "/kureckjones_jett_075_02-cropped.tif"); + for (TagGroup tagGroup : new TagGroup[] { + TagGroup.EXIF, TagGroup.IPTC, + TagGroup.XMP }) { + Map metadata = tool.getImageMeta5(f, + new ReadOptions(), Format.HUMAN_READABLE, tagGroup); + log.info(tagGroup.getKey()); + for (String key : metadata.keySet()) { + log.info(String.format("\t\t%s: %s", key, metadata.get(key))); + } + } + + tool.close(); + } } \ No newline at end of file diff --git a/src/test/java/com/thebuzzmedia/exiftool/TestExifTool.java b/src/test/java/com/thebuzzmedia/exiftool/TestExifTool.java index 5fdfd815..421a8ad2 100644 --- a/src/test/java/com/thebuzzmedia/exiftool/TestExifTool.java +++ b/src/test/java/com/thebuzzmedia/exiftool/TestExifTool.java @@ -1,8 +1,8 @@ package com.thebuzzmedia.exiftool; -import org.junit.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import java.io.File; import java.io.IOException; @@ -14,48 +14,63 @@ import java.nio.file.StandardCopyOption; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import static org.junit.Assert.*; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Joiner; +import com.thebuzzmedia.exiftool.adapters.ExifToolService; /** - * TestMetadata

- * + * TestMetadata + *

+ * * @author Michael Rush (michaelrush@gmail.com) * @since Initially created 8/8/13 */ -public class TestExifTool { +public class TestExifTool { + private static final String TEST_FILES_PATH = "src/test/resources"; private static Logger log = LoggerFactory.getLogger(TestExifTool.class); + private static ExifToolService create(Feature... features) { + return RawExifTool.Factory.create(features); + // return new ExifToolNew2(features); + // return new ExifToolNew(features); + } + + private static ExifToolService create(int timeoutWhenKeepAliveInMillis, Feature... features) { + return RawExifTool.Factory.create(timeoutWhenKeepAliveInMillis, features); + // return new ExifToolNew(timeoutWhenKeepAliveInMillis, features); + // return new ExifToolNew2(timeoutWhenKeepAliveInMillis, features); + } + + private ExifToolService create(ReadOptions readOptions, Feature... features) { + return RawExifTool.Factory.create(readOptions, features); + } + + private ReadOptions options = new ReadOptions(); + @Test public void testSingleTool() throws Exception { - ExifTool tool = new ExifTool(); - try { + try (ExifToolService tool = create(Feature.STAY_OPEN)) { assertTrue(runTests(tool, "")); - } finally { - tool.shutdown(); } - - tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - try { + try (ExifToolService tool = create(/* Feature.STAY_OPEN */)) { assertTrue(runTests(tool, "")); - } finally { - tool.shutdown(); } - tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - try { - tool.startup(); - assertTrue(runTests(tool, "")); - } finally { - tool.shutdown(); - } + // try (ExifToolService tool = create(Feature.STAY_OPEN)) { + // tool.startup(); + // assertTrue(runTests(tool, "")); + // } } @Test @@ -64,16 +79,14 @@ public void testConcurrent() throws Exception { int toolCount = 5; List threads = new ArrayList(toolCount); - for (int i=1; i <= toolCount; i++){ - String toolName = "tool"+i; - Thread t = new Thread(toolName){ + for (int i = 1; i <= toolCount; i++) { + String toolName = "tool" + i; + Thread t = new Thread(toolName) { @Override public void run() { - log.info(getName() + ": starting"); - ExifTool tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - try { - runTests(tool,getName()); - log.info(getName() + ": finished"); + log.debug(getName() + ": starting"); + try (ExifToolService tool = create(Feature.STAY_OPEN)) { + runTests(tool, getName()); } catch (IOException e) { log.error(e.getMessage(), e); fail(e.getMessage()); @@ -81,34 +94,33 @@ public void run() { log.error(e.getMessage(), e); fail(e.getMessage()); } finally { - tool.shutdown(); + log.debug(getName() + ": finished"); } - log.info(getName() + ": finished"); } }; t.start(); threads.add(t); } - for (Thread t : threads){ + for (Thread t : threads) { t.run(); } } @Test public void testManyThreadsOneTool() throws Exception { - final ExifTool tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - try { + try (ExifToolService tool = create(Feature.STAY_OPEN)) { Thread[] threads = new Thread[20]; - for(int i=0; i < threads.length; i++) { - final String label = "run "+i; + for (int i = 0; i < threads.length; i++) { + final String label = "run " + i; threads[i] = new Thread(new Runnable() { + @Override public void run() { try { - for(int j=0; j<5; j++) { - runTests(tool,label); + for (int j = 0; j < 5; j++) { + runTests(tool, label); } - log.info("DONE: "+label+" success!"); + log.debug("DONE: " + label + " success!"); } catch (IOException ex) { fail(label); } catch (URISyntaxException e) { @@ -116,364 +128,426 @@ public void run() { fail(e.getMessage()); } } - },label); + }, label); } - for(Thread thread : threads) { + for (Thread thread : threads) { thread.start(); } - for(Thread thread : threads) { + for (Thread thread : threads) { thread.join(); } - } finally { - tool.shutdown(); } } - @Test + @Test(expected = IOException.class) public void testProcessTimeout() throws Exception { - final ExifTool tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - try { - tool.setRunTimeout(1); - runTests(tool,"will fail"); - fail("should have failed"); - } catch (IOException ex) { - ; - } finally { - tool.shutdown(); + try (ExifToolService tool = create(1, Feature.STAY_OPEN)) { + long start = System.currentTimeMillis(); + runTests(tool, "will fail"); + long end = System.currentTimeMillis(); + fail("should have failed. passed " + (end - start) + " miliseconds."); } } - public boolean runTests(ExifTool tool, String runId) throws IOException, URISyntaxException { - - Map metadata; - File imageFile; - Set keys; - ExifTool.Tag tag; - - URL url = getClass().getResource("/kureckjones_jett_075_02-cropped.tif"); - imageFile = new File(url.toURI()); - metadata = tool.getImageMeta(imageFile, ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.values()); - assertEquals(22, metadata.size()); - - keys = metadata.keySet(); - - tag = ExifTool.Tag.IMAGE_WIDTH; - assertTrue(keys.contains(tag)); - assertEquals(728, tag.parseValue(metadata.get(tag))); - - tag = ExifTool.Tag.IMAGE_HEIGHT; - assertEquals(825, tag.parseValue(metadata.get(tag))); - - tag = ExifTool.Tag.MODEL; - assertEquals("P 45+", tag.parseValue(metadata.get(tag))); - log.info(runId + ": finished image 1"); - - - url = getClass().getResource("/nexus-s-electric-cars.jpg"); - imageFile = new File(url.toURI()); - metadata = tool.getImageMeta(imageFile, ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.values()); - assertEquals(23, metadata.size()); - - keys = metadata.keySet(); - tag = ExifTool.Tag.IMAGE_WIDTH; - assertTrue(keys.contains(tag)); - assertEquals(2560, tag.parseValue(metadata.get(tag))); - - tag = ExifTool.Tag.IMAGE_HEIGHT; - assertEquals(1920, tag.parseValue(metadata.get(tag))); - - tag = ExifTool.Tag.MODEL; - assertEquals("Nexus S", tag.parseValue(metadata.get(tag))); - - tag = ExifTool.Tag.ISO; - assertEquals(50, tag.parseValue(metadata.get(tag))); - - tag = ExifTool.Tag.SHUTTER_SPEED; - assertEquals("1/64", metadata.get(tag)); - assertEquals(0.015625, tag.parseValue(metadata.get(tag))); - log.info(runId + ": finished image 2"); + public boolean runTests(ExifToolService tool, String runId) throws IOException, URISyntaxException { + testFile(tool, "/kureckjones_jett_075_02-cropped.tif", 32, 728, 825, "P 45+", 100, "1/6", 0.16666); + testFile(tool, "/nexus-s-electric-cars.jpg", 27, 2560, 1920, "Nexus S", 50, "1/64", 0.015625); + // Map rawMetadata = tool.getImageMeta(imageFile, new + // ReadOptions().withNumericOutput(Format.HUMAN_READABLE)); + // assertEquals(231, rawMetadata.size()); return true; } + private void testFile(ExifToolService tool, String resource, int size, int width, int height, String model, int iso, String shutterRaw, + double shutter) throws URISyntaxException, IOException { + File imageFile = new File(getClass().getResource(resource).toURI()); + Map metadata = tool.getImageMeta4d(imageFile, new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), + Tag.values()); + assertEquals(size, metadata.size()); + assertEquals((Integer) width, Tag.IMAGE_WIDTH.getValue(metadata)); + assertEquals((Integer) height, Tag.IMAGE_HEIGHT.getValue(metadata)); + assertEquals(model, Tag.MODEL.getValue(metadata)); + assertEquals((Integer) iso, Tag.ISO.getValue(metadata)); + assertEquals(shutterRaw, Tag.SHUTTER_SPEED.getRawValue(metadata)); + assertEquals(shutter, (Double) Tag.SHUTTER_SPEED.getValue(metadata), 1e-5); + } + @Test public void testGroupTags() throws Exception { - ExifTool tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - try { - Map metadata; + try (ExifToolService tool = create(Feature.STAY_OPEN)) { + Map metadata; URL url = getClass().getResource("/iptc_test-photoshop.jpg"); File f = new File(url.toURI()); - metadata = tool.getImageMeta(f, ExifTool.Format.HUMAN_READABLE, ExifTool.TagGroup.IPTC); + metadata = tool.getImageMeta5(f, options, Format.HUMAN_READABLE, TagGroup.IPTC); assertEquals(17, metadata.size()); assertEquals("IPTC Content: Keywords", metadata.get("Keywords")); assertEquals("IPTC Status: Copyright Notice", metadata.get("CopyrightNotice")); assertEquals("IPTC Content: Description Writer", metadata.get("Writer-Editor")); - //for (String key : metadata.keySet()){ - // log.info(String.format("\t\t%s: %s", key, metadata.get(key))); - //} - } finally { - tool.shutdown(); + // for (String key : metadata.keySet()){ + // log.info(String.format("\t\t%s: %s", key, metadata.get(key))); + // } } } @Test - public void testTag(){ - assertEquals("string value", "John Doe", ExifTool.Tag.AUTHOR.parseValue("John Doe")); - assertEquals("integer value", 200, ExifTool.Tag.ISO.parseValue("200")); - assertEquals("double value, from fraction", .25, ExifTool.Tag.SHUTTER_SPEED.parseValue("1/4")); - assertEquals("double value, from decimal", .25, ExifTool.Tag.SHUTTER_SPEED.parseValue(".25")); + public void testTag() { + assertEquals("string value", "John Doe", Tag.AUTHOR.parseValue("John Doe")); + assertEquals("integer value", (Integer) 200, Tag.ISO.parseValue("200")); + assertEquals("double value, from fraction", (Double) .25, Tag.SHUTTER_SPEED.parseValue("1/4")); + assertEquals("double value, from decimal", (Double) .25, Tag.SHUTTER_SPEED.parseValue(".25")); } @Test public void testVersionNumber() { - assertTrue(new ExifTool.VersionNumber("1.2").isBeforeOrEqualTo(new ExifTool.VersionNumber("1.2.3"))); - assertTrue(new ExifTool.VersionNumber(1,2).isBeforeOrEqualTo(new ExifTool.VersionNumber("1.2"))); - assertTrue(new ExifTool.VersionNumber(1,2,3).isBeforeOrEqualTo(new ExifTool.VersionNumber("1.3"))); - assertTrue(new ExifTool.VersionNumber(1,2,3).isBeforeOrEqualTo(new ExifTool.VersionNumber(2,1))); + assertTrue(new VersionNumber("1.2").isBeforeOrEqualTo(new VersionNumber("1.2.3"))); + assertTrue(new VersionNumber(1, 2).isBeforeOrEqualTo(new VersionNumber("1.2"))); + assertTrue(new VersionNumber(1, 2, 3).isBeforeOrEqualTo(new VersionNumber("1.3"))); + assertTrue(new VersionNumber(1, 2, 3).isBeforeOrEqualTo(new VersionNumber(2, 1))); } @Test - public void testWriteTagStringNonDaemon() throws Exception{ - ExifTool tool = new ExifTool(); - URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); - Path imageFile = Paths.get(url.toURI()); - - // Check the value is correct at the start - Map metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Now change it - String newDate = "2014:01:23 10:07:05"; - Map newValues = new HashMap(); - newValues.put(ExifTool.Tag.DATE_TIME_ORIGINAL, newDate); - tool.addImageMetadata(imageFile.toFile(), newValues); - - // Finally check that it's updated - metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("DateTimeOriginal tag is wrong", newDate, metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Finally copy the source file back over so the next test run is not affected by the change - URL backup_url = getClass().getResource("/nexus-s-electric-cars.jpg_original"); - Path backupFile = Paths.get(backup_url.toURI()); - - Files.move(backupFile, imageFile, StandardCopyOption.REPLACE_EXISTING); + public void testWriteTagStringNonDaemon() throws Exception { + try (ExifToolService tool = create()) { + URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); + Path imageFile = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(url.toURI()), imageFile, StandardCopyOption.REPLACE_EXISTING); + MetadataTag[] tags = { Tag.DATE_TIME_ORIGINAL }; + + // Check the value is correct at the start + Map metadata = tool.getImageMeta4d(imageFile.toFile(), + new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags); + assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(Tag.DATE_TIME_ORIGINAL)); + + // Now change it + String newDate = "2014:01:23 10:07:05"; + Map newValues = new HashMap(); + newValues.put(Tag.DATE_TIME_ORIGINAL, newDate); + tool.addImageMetadata(imageFile.toFile(), newValues); + MetadataTag[] tags1 = { Tag.DATE_TIME_ORIGINAL }; + + // Finally check that it's updated + metadata = tool.getImageMeta4d(imageFile.toFile(), new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags1); + assertEquals("DateTimeOriginal tag is wrong", newDate, metadata.get(Tag.DATE_TIME_ORIGINAL)); + } } @Test - public void testWriteTagString() throws Exception{ - ExifTool tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); - Path imageFile = Paths.get(url.toURI()); - - // Check the value is correct at the start - Map metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Now change it - String newDate = "2014:01:23 10:07:05"; - Map newValues = new HashMap(); - newValues.put(ExifTool.Tag.DATE_TIME_ORIGINAL, newDate); - tool.addImageMetadata(imageFile.toFile(), newValues); - - // Finally check that it's updated - metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("DateTimeOriginal tag is wrong", newDate, metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Finally copy the source file back over so the next test run is not affected by the change - URL backup_url = getClass().getResource("/nexus-s-electric-cars.jpg_original"); - Path backupFile = Paths.get(backup_url.toURI()); - - Files.move(backupFile, imageFile, StandardCopyOption.REPLACE_EXISTING); + public void testWriteTagString() throws Exception { + try (ExifToolService tool = create(Feature.STAY_OPEN)) { + URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); + Path imageFile = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(url.toURI()), imageFile, StandardCopyOption.REPLACE_EXISTING); + MetadataTag[] tags = { Tag.DATE_TIME_ORIGINAL }; + + // Check the value is correct at the start + Map metadata = tool.getImageMeta4d(imageFile.toFile(), + new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags); + assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(Tag.DATE_TIME_ORIGINAL)); + + // Now change it + String newDate = "2014:01:23 10:07:05"; + Map newValues = new HashMap(); + newValues.put(Tag.DATE_TIME_ORIGINAL, newDate); + tool.addImageMetadata(imageFile.toFile(), newValues); + MetadataTag[] tags1 = { Tag.DATE_TIME_ORIGINAL }; + + // Finally check that it's updated + metadata = tool.getImageMeta4d(imageFile.toFile(), new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags1); + assertEquals("DateTimeOriginal tag is wrong", newDate, metadata.get(Tag.DATE_TIME_ORIGINAL)); + } } - @Test - public void testWriteTagStringInvalidformat() throws Exception{ - ExifTool tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); - Path imageFile = Paths.get(url.toURI()); - - // Check the value is correct at the start - Map metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - String newDate = "2egek opkpgrpok"; - Map newValues = new HashMap(); - newValues.put(ExifTool.Tag.DATE_TIME_ORIGINAL, newDate); - - // Now change it to an invalid value which should fail - tool.addImageMetadata(imageFile.toFile(), newValues); - - // Finally check that it's not updated - metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("DateTimeOriginal tag is wrong", "2010:12:10 17:07:05", metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Finally copy the source file back over so the next test run is not affected by the change - URL backup_url = getClass().getResource("/nexus-s-electric-cars.jpg_original"); - // might not exist - if(backup_url != null) { - Path backupFile = Paths.get(backup_url.toURI()); - Files.move(backupFile, imageFile, StandardCopyOption.REPLACE_EXISTING); + @Test(expected = ExifError.class) + public void testWriteTagStringInvalidformat() throws Exception { + try (ExifToolService tool = create(Feature.STAY_OPEN)) { + Path imageFile = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(getClass().getResource("/nexus-s-electric-cars.jpg").toURI()), imageFile, StandardCopyOption.REPLACE_EXISTING); + + // Check the value is correct at the start + Map metadata = tool.getImageMeta4d(imageFile.toFile(), + new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), Tag.DATE_TIME_ORIGINAL); + String initial = "2010:12:10 17:07:05"; + assertEquals("Wrong starting value", initial, Tag.DATE_TIME_ORIGINAL.getRawValue(metadata)); + + String newDate = "2egek opkpgrpok"; + Map newValues = new HashMap(); + newValues.put(Tag.DATE_TIME_ORIGINAL, newDate); + ExifError error = null; + try { + // Now change it to an invalid value which should fail + tool.addImageMetadata(imageFile.toFile(), newValues); + } catch (ExifError e) { + error = e; + } + // Finally check that it's not updated + metadata = tool.getImageMeta4d(imageFile.toFile(), new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), + Tag.DATE_TIME_ORIGINAL); + assertEquals("DateTimeOriginal tag is wrong", initial, Tag.DATE_TIME_ORIGINAL.getRawValue(metadata)); + throw error; } } @Test - public void testWriteTagNumberNonDaemon() throws Exception{ - ExifTool tool = new ExifTool(); - URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); - Path imageFile = Paths.get(url.toURI()); - - // Test what orientation value is at the start - Map metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.ORIENTATION); - assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", metadata.get(ExifTool.Tag.ORIENTATION)); - - // Now change it - Map newValues = new HashMap(); - newValues.put(ExifTool.Tag.ORIENTATION, 3); + public void testWriteTagNumberNonDaemon() throws Exception { + try (ExifToolService tool = create()) { + URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); + Path imageFile = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(url.toURI()), imageFile, StandardCopyOption.REPLACE_EXISTING); + MetadataTag[] tags = { Tag.ORIENTATION }; - tool.addImageMetadata(imageFile.toFile(), newValues); + // Test what orientation value is at the start + Map metadata = tool.getImageMeta4d(imageFile.toFile(), + new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags); + assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", metadata.get(Tag.ORIENTATION)); - // Finally check the updated value - metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.ORIENTATION); - assertEquals("Orientation tag updated value is wrong", "Rotate 180", metadata.get(ExifTool.Tag.ORIENTATION)); + // Now change it + Map newValues = new HashMap(); + newValues.put(Tag.ORIENTATION, 3); - // Finally copy the source file back over so the next test run is not affected by the change - URL backup_url = getClass().getResource("/nexus-s-electric-cars.jpg_original"); - Path backupFile = Paths.get(backup_url.toURI()); + tool.addImageMetadata(imageFile.toFile(), newValues); + MetadataTag[] tags1 = { Tag.ORIENTATION }; - Files.move(backupFile, imageFile, StandardCopyOption.REPLACE_EXISTING); + // Finally check the updated value + metadata = tool.getImageMeta4d(imageFile.toFile(), new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags1); + assertEquals("Orientation tag updated value is wrong", "Rotate 180", metadata.get(Tag.ORIENTATION)); + } } @Test - public void testWriteTagNumber() throws Exception{ - ExifTool tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); - Path imageFile = Paths.get(url.toURI()); + public void testWriteTagNumber() throws Exception { + try (ExifToolService tool = create(Feature.STAY_OPEN)) { + URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); + Path imageFile = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(url.toURI()), imageFile, StandardCopyOption.REPLACE_EXISTING); + MetadataTag[] tags = { Tag.ORIENTATION }; - // Test what orientation value is at the start - Map metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.ORIENTATION); - assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", metadata.get(ExifTool.Tag.ORIENTATION)); + // Test what orientation value is at the start + Map metadata = tool.getImageMeta4d(imageFile.toFile(), + new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags); + assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", metadata.get(Tag.ORIENTATION)); - // Now change it - Map newValues = new HashMap(); - newValues.put(ExifTool.Tag.ORIENTATION, 3); + // Now change it + Map newValues = new HashMap(); + newValues.put(Tag.ORIENTATION, 3); - tool.addImageMetadata(imageFile.toFile(), newValues); + tool.addImageMetadata(imageFile.toFile(), newValues); + MetadataTag[] tags1 = { Tag.ORIENTATION }; - // Finally check the updated value - metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.ORIENTATION); - assertEquals("Orientation tag updated value is wrong", "Rotate 180", metadata.get(ExifTool.Tag.ORIENTATION)); + // Finally check the updated value + metadata = tool.getImageMeta4d(imageFile.toFile(), new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags1); + assertEquals("Orientation tag updated value is wrong", "Rotate 180", metadata.get(Tag.ORIENTATION)); + } + } + + @Test + public void testWriteMulipleTag() throws Exception { + try (ExifToolService tool = create(Feature.STAY_OPEN)) { + Path imageFile = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(getClass().getResource("/nexus-s-electric-cars.jpg").toURI()), imageFile, StandardCopyOption.REPLACE_EXISTING); + // Test what orientation value is at the start + Map metadata = tool.getImageMeta4d(imageFile.toFile(), + new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), Tag.ORIENTATION, Tag.DATE_TIME_ORIGINAL); + assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", Tag.ORIENTATION.getRawValue(metadata)); + assertEquals("Wrong starting value", "2010:12:10 17:07:05", Tag.DATE_TIME_ORIGINAL.getRawValue(metadata)); + + // Now change them + String newDate = "2014:01:23 10:07:05"; + Map newValues = new HashMap(); + newValues.put(Tag.DATE_TIME_ORIGINAL, newDate); + newValues.put(Tag.ORIENTATION, 3); - // Finally copy the source file back over so the next test run is not affected by the change - URL backup_url = getClass().getResource("/nexus-s-electric-cars.jpg_original"); - Path backupFile = Paths.get(backup_url.toURI()); + tool.addImageMetadata(imageFile.toFile(), newValues); - Files.move(backupFile, imageFile, StandardCopyOption.REPLACE_EXISTING); + // Finally check the updated value + metadata = tool.getImageMeta4d(imageFile.toFile(), new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), Tag.ORIENTATION, + Tag.DATE_TIME_ORIGINAL); + assertEquals("Orientation tag updated value is wrong", "Rotate 180", Tag.ORIENTATION.getRawValue(metadata)); + assertEquals("DateTimeOriginal tag is wrong", newDate, Tag.DATE_TIME_ORIGINAL.getRawValue(metadata)); + } } @Test - public void testWriteMultipleTag() throws Exception{ - ExifTool tool = new ExifTool(ExifTool.Feature.STAY_OPEN); - URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); - Path imageFile = Paths.get(url.toURI()); - - // Test what orientation value is at the start - Map metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.ORIENTATION, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", metadata.get(ExifTool.Tag.ORIENTATION)); - assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Now change them - String newDate = "2014:01:23 10:07:05"; - Map newValues = new HashMap(); - newValues.put(ExifTool.Tag.DATE_TIME_ORIGINAL, newDate); - newValues.put(ExifTool.Tag.ORIENTATION, 3); - - tool.addImageMetadata(imageFile.toFile(), newValues); - - // Finally check the updated value - metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.ORIENTATION, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("Orientation tag updated value is wrong", "Rotate 180", metadata.get(ExifTool.Tag.ORIENTATION)); - assertEquals("DateTimeOriginal tag is wrong", newDate, metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Finally copy the source file back over so the next test run is not affected by the change - URL backup_url = getClass().getResource("/nexus-s-electric-cars.jpg_original"); - Path backupFile = Paths.get(backup_url.toURI()); - - Files.move(backupFile, imageFile, StandardCopyOption.REPLACE_EXISTING); + public void testWriteMulipleTagNonDaemon() throws Exception { + try (ExifToolService tool = create()) { + URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); + Path imageFile = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(url.toURI()), imageFile, StandardCopyOption.REPLACE_EXISTING); + MetadataTag[] tags = { Tag.ORIENTATION, Tag.DATE_TIME_ORIGINAL }; + + // Test what orientation value is at the start + Map metadata = tool.getImageMeta4d(imageFile.toFile(), + new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags); + assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", metadata.get(Tag.ORIENTATION)); + assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(Tag.DATE_TIME_ORIGINAL)); + + // Now change them + String newDate = "2014:01:23 10:07:05"; + Map newValues = new HashMap(); + newValues.put(Tag.DATE_TIME_ORIGINAL, newDate); + newValues.put(Tag.ORIENTATION, 3); + + tool.addImageMetadata(imageFile.toFile(), newValues); + MetadataTag[] tags1 = { Tag.ORIENTATION, Tag.DATE_TIME_ORIGINAL }; + + // Finally check the updated value + metadata = tool.getImageMeta4d(imageFile.toFile(), new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags1); + assertEquals("Orientation tag updated value is wrong", "Rotate 180", metadata.get(Tag.ORIENTATION)); + assertEquals("DateTimeOriginal tag is wrong", newDate, metadata.get(Tag.DATE_TIME_ORIGINAL)); + } } @Test - public void testWriteMultipleTagNonDaemon() throws Exception{ - ExifTool tool = new ExifTool(); - URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); - Path imageFile = Paths.get(url.toURI()); - - // Test what orientation value is at the start - Map metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.ORIENTATION, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", metadata.get(ExifTool.Tag.ORIENTATION)); - assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Now change them - String newDate = "2014:01:23 10:07:05"; - Map newValues = new HashMap(); - newValues.put(ExifTool.Tag.DATE_TIME_ORIGINAL, newDate); - newValues.put(ExifTool.Tag.ORIENTATION, 3); - - tool.writeMetadata(imageFile.toFile(), newValues); - - // Finally check the updated value - metadata = tool.getImageMeta(imageFile.toFile(), ExifTool.Format.HUMAN_READABLE, ExifTool.Tag.ORIENTATION, ExifTool.Tag.DATE_TIME_ORIGINAL); - assertEquals("Orientation tag updated value is wrong", "Rotate 180", metadata.get(ExifTool.Tag.ORIENTATION)); - assertEquals("DateTimeOriginal tag is wrong", newDate, metadata.get(ExifTool.Tag.DATE_TIME_ORIGINAL)); - - // Finally copy the source file back over so the next test run is not affected by the change - URL backup_url = getClass().getResource("/nexus-s-electric-cars.jpg_original"); - Path backupFile = Paths.get(backup_url.toURI()); - - Files.move(backupFile, imageFile, StandardCopyOption.REPLACE_EXISTING); + public void testWriteMultipleTagNonDaemon2() throws Exception { + try (ExifToolService tool = create()) { + URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); + Path imageFile = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(url.toURI()), imageFile, StandardCopyOption.REPLACE_EXISTING); + MetadataTag[] tags = { Tag.ORIENTATION, Tag.DATE_TIME_ORIGINAL }; + + // Test what orientation value is at the start + Map metadata = tool.getImageMeta4d(imageFile.toFile(), + new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags); + assertEquals("Orientation tag starting value is wrong", "Horizontal (normal)", metadata.get(Tag.ORIENTATION)); + assertEquals("Wrong starting value", "2010:12:10 17:07:05", metadata.get(Tag.DATE_TIME_ORIGINAL)); + + // Now change them + String newDate = "2014:01:23 10:07:05"; + Map newValues = new HashMap(); + newValues.put(Tag.DATE_TIME_ORIGINAL, newDate); + newValues.put(Tag.ORIENTATION, 3); + + // tool.writeMetadata(imageFile.toFile(), newValues); + tool.addImageMetadata(imageFile.toFile(), newValues); + MetadataTag[] tags1 = { Tag.ORIENTATION, Tag.DATE_TIME_ORIGINAL }; + + // Finally check the updated value + metadata = tool.getImageMeta4d(imageFile.toFile(), new ReadOptions().withNumericOutput(Format.HUMAN_READABLE), tags1); + assertEquals("Orientation tag updated value is wrong", "Rotate 180", metadata.get(Tag.ORIENTATION)); + assertEquals("DateTimeOriginal tag is wrong", newDate, metadata.get(Tag.DATE_TIME_ORIGINAL)); + } } @Test - public void testWritingWithImplicitTypes() throws Exception{ - ExifTool tool = new ExifTool(ExifTool.Feature.MWG_MODULE); - tool.setReadOptions(tool.getReadOptions().withNumericOutput(true).withConvertTypes(true)); - URL url = getClass().getResource("/nexus-s-electric-cars.jpg"); - File imageFile = Paths.get(url.toURI()).toFile(); - try { + public void testWritingWithImplicitTypes() throws Exception { + try (ExifToolService tool = create(new ReadOptions().withNumericOutput(true).withConvertTypes(true), Feature.MWG_MODULE)) { + Path imagePath = Paths.get("target", "nexus-s-electric-cars-tochange.jpg"); + Files.copy(Paths.get(getClass().getResource("/nexus-s-electric-cars.jpg").toURI()), imagePath, StandardCopyOption.REPLACE_EXISTING); + File imageFile = imagePath.toFile(); // Test what orientation value is at the start SimpleDateFormat formatter = new SimpleDateFormat("yyyy:MM:dd hh:mm:ss"); - Map metadata = tool.readMetadata(imageFile, ExifTool.Tag.ORIENTATION, ExifTool.MwgTag.DATE_TIME_ORIGINAL); - assertEquals("Orientation tag starting value is wrong", 1, metadata.get(ExifTool.Tag.ORIENTATION)); - assertEquals("Wrong starting value", formatter.parse("2010:12:10 17:07:05"), metadata.get(ExifTool.MwgTag.DATE_TIME_ORIGINAL)); + Map metadata = tool.getImageMeta2(imageFile, options.withNumericOutput(true), Tag.ORIENTATION, + MwgTag.DATE_TIME_ORIGINAL); + assertEquals("Orientation tag starting value is wrong", (Integer) 1, Tag.ORIENTATION.getValue(metadata)); + assertEquals("Wrong starting value", formatter.parse("2010:12:10 17:07:05"), MwgTag.DATE_TIME_ORIGINAL.getValue(metadata)); // Now change them Map data = new HashMap(); Date dateTimeOrig = formatter.parse("2014:01:23 10:07:05"); - data.put(ExifTool.MwgTag.DATE_TIME_ORIGINAL, dateTimeOrig); - data.put(ExifTool.Tag.ORIENTATION, 3); + data.put(MwgTag.DATE_TIME_ORIGINAL, dateTimeOrig); + data.put(Tag.ORIENTATION, 3); Date createDate = formatter.parse("2013:02:21 10:07:05"); - data.put(ExifTool.MwgTag.CREATE_DATE, createDate.getTime()); - data.put(ExifTool.MwgTag.KEYWORDS, new String[]{"a", "b", "c"}); - tool.writeMetadata(tool.getWriteOptions().withDeleteBackupFile(false),imageFile, data); + data.put(MwgTag.CREATE_DATE, createDate.getTime()); + data.put(MwgTag.KEYWORDS, new String[] { "a", "b", "c" }); + tool.writeMetadata(new WriteOptions().withDeleteBackupFile(false), imageFile, data); // Finally check the updated value - metadata = tool.readMetadata(imageFile, ExifTool.Tag.ORIENTATION, imageFile, ExifTool.MwgTag.DATE_TIME_ORIGINAL, ExifTool.MwgTag.CREATE_DATE, ExifTool.MwgTag.KEYWORDS); - assertEquals("Orientation tag updated value is wrong", 3, metadata.get(ExifTool.Tag.ORIENTATION)); - assertEquals("DateTimeOriginal tag is wrong", dateTimeOrig, metadata.get(ExifTool.MwgTag.DATE_TIME_ORIGINAL)); - assertEquals("CreateDate tag is wrong", createDate, metadata.get(ExifTool.MwgTag.CREATE_DATE)); - assertEquals("Keywords tag is wrong", "a", ((String[]) metadata.get(ExifTool.MwgTag.KEYWORDS))[0]); - - // Finally copy the source file back over so the next test run is not affected by the change - - } finally { - URL backup_url = getClass().getResource("/nexus-s-electric-cars.jpg_original"); - if ( backup_url != null ) { - Path backupFile = Paths.get(backup_url.toURI()); - Files.move(backupFile, imageFile.toPath(), StandardCopyOption.REPLACE_EXISTING); - } + metadata = tool.getImageMeta6(imageFile, options.withNumericOutput(true), Tag.ORIENTATION, MwgTag.DATE_TIME_ORIGINAL, + MwgTag.CREATE_DATE, MwgTag.KEYWORDS); + assertEquals("Orientation tag updated value is wrong", (Integer) 3, Tag.ORIENTATION.getValue(metadata)); + assertEquals("DateTimeOriginal tag is wrong", dateTimeOrig, MwgTag.DATE_TIME_ORIGINAL.getValue(metadata)); + assertEquals("CreateDate tag is wrong", createDate, MwgTag.CREATE_DATE.getValue(metadata)); + assertEquals("Keywords tag is wrong", "a", ((String[]) MwgTag.KEYWORDS.getValue(metadata))[0]); + List keys = Arrays.asList(((String[]) MwgTag.KEYWORDS.getValue(metadata))); + assertEquals("Keywords tag is wrong", 3, keys.size()); + assertEquals("Keywords tag is wrong", "a:b:c", Joiner.on(":").join(keys)); + } + } + + @Test // (expected = ExifError.class) + public void testReadingUtf8NamesWithStayOpen() throws Exception { + try (ExifToolService tool = create(new ReadOptions().withNumericOutput(true).withConvertTypes(true), Feature.STAY_OPEN)) { + URL url = getClass().getResource("/20140502_152336_Östliche Zubringerstraße.png"); + File imageFile = new File(url.toURI()); + Map metadata = tool.getImageMeta3(imageFile, options); + // should fail on the line before. this is just for breakpoint and retry + Map metadata2 = tool.getImageMeta3(imageFile, options); + assertEquals(21, metadata2.size()); + } + } + + @Test // (expected = ExifError.class) + public void testReadingUtf8NamesWithStayOpenWithoutSpaces() throws Exception { + try (ExifToolService tool = create(new ReadOptions().withNumericOutput(true).withConvertTypes(true), Feature.STAY_OPEN)) { + URL url = getClass().getResource("/20140502_152336_Östliche_Zubringerstraße.png"); + File imageFile = new File(url.toURI()); + // System.out.println(imageFile.getAbsolutePath()); + Map metadata = tool.getImageMeta3(imageFile, options); + assertEquals(21, metadata.size()); + } + } + @Test + public void testReadingUtf8NamesWithoutStayOpen() throws Exception { + try (ExifToolService tool = create(new ReadOptions().withNumericOutput(true).withConvertTypes(true))) { + URL url = getClass().getResource("/20140502_152336_Östliche Zubringerstraße.png"); + File imageFile = new File(url.toURI()); + // System.out.println(imageFile.getAbsolutePath()); + Map metadata = tool.getImageMeta3(imageFile, options); + assertEquals(21, metadata.size()); + } + } + + @Test + public void testReadingUtf8NamesWithStayOpenAndWindows() throws Exception { + try (ExifToolService tool = create(new ReadOptions().withNumericOutput(true).withConvertTypes(true), Feature.STAY_OPEN, + Feature.WINDOWS)) { + URL url = getClass().getResource("/20140502_152336_Östliche Zubringerstraße.png"); + File imageFile = new File(url.toURI()); + // System.out.println(imageFile.getAbsolutePath()); + Map metadata = tool.getImageMeta3(imageFile, options); + assertEquals(21, metadata.size()); } } - //todo TEST automatic daemon restart by killing perl process + @Test + public void testReadingUtf8NamesOnWindows() throws Exception { + try (ExifToolService tool = create(new ReadOptions().withNumericOutput(true).withConvertTypes(true), Feature.STAY_OPEN, + Feature.WINDOWS)) { + URL url = getClass().getResource("/20131231_230955_Strada Frumoasă.png"); + File imageFile = new File(url.toURI()); + // System.out.println(imageFile.getAbsolutePath()); + Map metadata1 = tool.getImageMeta(imageFile, options); + assertEquals(21, metadata1.size()); + // System.out.println(metadata1); + Map metadata = tool.getImageMeta3(imageFile, options); + assertEquals(21, metadata.size()); + } + } + + @Test + public void testReadingOnWindowsEndOfLine() throws Exception { + ExifToolNew3 exifTool = new ExifToolNew3(Feature.WINDOWS, Feature.STAY_OPEN); + URL url = getClass().getResource("/20131231_230955_Strada Frumoasă.png"); + File imageFile = new File(url.toURI()); + Map map = exifTool.getImageMeta(imageFile, + new ReadOptions() + , + "GPSAltitude", + "GPSLatitude", + "GPSLatitudeRef", + "GPSLongitude", + "GPSLongitudeRef", + "GPSAltitude", + "GPSAltitudeRef", + "GPSSpeed", + "GPSSpeedRef", + "GPSProcessingMethod", + "GPSDestBearing", + "GPSDestBearingRef", + "GPSTimeStamp" + ); + System.out.println(map); + } } diff --git "a/src/test/resources/20131231_230955_Strada Frumoas\304\203.png" "b/src/test/resources/20131231_230955_Strada Frumoas\304\203.png" new file mode 100644 index 00000000..e8485dbc Binary files /dev/null and "b/src/test/resources/20131231_230955_Strada Frumoas\304\203.png" differ diff --git "a/src/test/resources/20140502_152336_\303\226stliche Zubringerstra\303\237e.png" "b/src/test/resources/20140502_152336_\303\226stliche Zubringerstra\303\237e.png" new file mode 100644 index 00000000..e8485dbc Binary files /dev/null and "b/src/test/resources/20140502_152336_\303\226stliche Zubringerstra\303\237e.png" differ diff --git "a/src/test/resources/20140502_152336_\303\226stliche_Zubringerstra\303\237e.png" "b/src/test/resources/20140502_152336_\303\226stliche_Zubringerstra\303\237e.png" new file mode 100644 index 00000000..e8485dbc Binary files /dev/null and "b/src/test/resources/20140502_152336_\303\226stliche_Zubringerstra\303\237e.png" differ diff --git a/src/test/resources/simplelogger.properties b/src/test/resources/simplelogger.properties index 91219c09..109bd981 100644 --- a/src/test/resources/simplelogger.properties +++ b/src/test/resources/simplelogger.properties @@ -1 +1 @@ - org.slf4j.simpleLogger.defaultLogLevel=debug \ No newline at end of file + org.slf4j.simpleLogger.defaultLogLevel=warn \ No newline at end of file diff --git a/src/test/test.iml b/src/test/test.iml deleted file mode 100644 index 2c1f253e..00000000 --- a/src/test/test.iml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - -