001/* 002 * (C) Copyright 2014-2015 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * vpasquier <vpasquier@nuxeo.com> 018 */ 019package org.nuxeo.binary.metadata.internals; 020 021import java.io.File; 022import java.io.IOException; 023import java.io.InputStream; 024import java.nio.file.Files; 025import java.nio.file.StandardCopyOption; 026import java.text.ParseException; 027import java.text.SimpleDateFormat; 028import java.util.ArrayList; 029import java.util.Arrays; 030import java.util.Calendar; 031import java.util.Collection; 032import java.util.Collections; 033import java.util.Date; 034import java.util.HashMap; 035import java.util.List; 036import java.util.Map; 037import java.util.regex.Pattern; 038import java.util.stream.Collectors; 039 040import org.apache.commons.io.FilenameUtils; 041import org.apache.commons.lang.ObjectUtils; 042import org.apache.commons.logging.Log; 043import org.apache.commons.logging.LogFactory; 044import org.codehaus.jackson.map.ObjectMapper; 045import org.codehaus.jackson.type.TypeReference; 046import org.nuxeo.binary.metadata.api.BinaryMetadataConstants; 047import org.nuxeo.binary.metadata.api.BinaryMetadataException; 048import org.nuxeo.binary.metadata.api.BinaryMetadataProcessor; 049import org.nuxeo.ecm.core.api.Blob; 050import org.nuxeo.ecm.core.api.CloseableFile; 051import org.nuxeo.ecm.core.api.impl.blob.FileBlob; 052import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters; 053import org.nuxeo.ecm.platform.commandline.executor.api.CommandAvailability; 054import org.nuxeo.ecm.platform.commandline.executor.api.CommandLineExecutorService; 055import org.nuxeo.ecm.platform.commandline.executor.api.CommandNotAvailable; 056import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult; 057import org.nuxeo.runtime.api.Framework; 058 059/** 060 * @since 7.1 061 */ 062public class ExifToolProcessor implements BinaryMetadataProcessor { 063 064 private static final Log log = LogFactory.getLog(ExifToolProcessor.class); 065 066 private static final String META_NON_USED_SOURCE_FILE = "SourceFile"; 067 068 private static final String DATE_FORMAT_PATTERN = "yyyy:MM:dd HH:mm:ss"; 069 070 private static final String EXIF_IMAGE_DATE_TIME = "EXIF:DateTime"; 071 072 private static final String EXIF_PHOTO_DATE_TIME_ORIGINAL = "EXIF:DateTimeOriginal"; 073 074 private static final String EXIF_PHOTO_DATE_TIME_DIGITIZED = "EXIF:DateTimeDigitized"; 075 076 protected final ObjectMapper jacksonMapper; 077 078 protected final CommandLineExecutorService commandLineService; 079 080 public ExifToolProcessor() { 081 jacksonMapper = new ObjectMapper(); 082 commandLineService = Framework.getService(CommandLineExecutorService.class); 083 } 084 085 @Override 086 public Blob writeMetadata(Blob blob, Map<String, Object> metadata, boolean ignorePrefix) { 087 String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_WRITE_NOPREFIX 088 : BinaryMetadataConstants.EXIFTOOL_WRITE; 089 CommandAvailability ca = commandLineService.getCommandAvailability(command); 090 if (!ca.isAvailable()) { 091 throw new BinaryMetadataException("Command '" + command + "' is not available."); 092 } 093 if (blob == null) { 094 throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob"); 095 } 096 try { 097 Blob newBlob = getTemporaryBlob(blob); 098 CmdParameters params = commandLineService.getDefaultCmdParameters(); 099 params.addNamedParameter("inFilePath", newBlob.getFile()); 100 params.addNamedParameter("tagList", getCommandTags(metadata)); 101 ExecResult er = commandLineService.execCommand(command, params); 102 boolean success = er.isSuccessful(); 103 if (!success) { 104 log.error("There was an error executing " + "the following command: " + er.getCommandLine() + ". \n" 105 + er.getOutput()); 106 return null; 107 } 108 newBlob.setMimeType(blob.getMimeType()); 109 newBlob.setEncoding(blob.getEncoding()); 110 newBlob.setFilename(blob.getFilename()); 111 return newBlob; 112 } catch (CommandNotAvailable commandNotAvailable) { 113 throw new BinaryMetadataException("Command '" + command + "' is not available.", commandNotAvailable); 114 } catch (IOException ioException) { 115 throw new BinaryMetadataException(ioException); 116 } 117 } 118 119 protected Map<String, Object> readMetadata(String command, Blob blob, List<String> metadata, boolean ignorePrefix) { 120 CommandAvailability ca = commandLineService.getCommandAvailability(command); 121 if (!ca.isAvailable()) { 122 throw new BinaryMetadataException("Command '" + command + "' is not available."); 123 } 124 if (blob == null) { 125 throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob"); 126 } 127 try { 128 ExecResult er; 129 try (CloseableFile source = getTemporaryFile(blob)) { 130 CmdParameters params = commandLineService.getDefaultCmdParameters(); 131 params.addNamedParameter("inFilePath", source.getFile()); 132 if (metadata != null) { 133 params.addNamedParameter("tagList", getCommandTags(metadata)); 134 } 135 er = commandLineService.execCommand(command, params); 136 } 137 return returnResultMap(er); 138 } catch (CommandNotAvailable commandNotAvailable) { 139 throw new RuntimeException("Command '" + command + "' is not available.", commandNotAvailable); 140 } catch (IOException ioException) { 141 throw new BinaryMetadataException(ioException); 142 } 143 } 144 145 @Override 146 public Map<String, Object> readMetadata(Blob blob, List<String> metadata, boolean ignorePrefix) { 147 String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST_NOPREFIX 148 : BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST; 149 return readMetadata(command, blob, metadata, ignorePrefix); 150 } 151 152 @Override 153 public Map<String, Object> readMetadata(Blob blob, boolean ignorePrefix) { 154 String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_NOPREFIX 155 : BinaryMetadataConstants.EXIFTOOL_READ; 156 return readMetadata(command, blob, null, ignorePrefix); 157 } 158 159 /*--------------------------- Utils ------------------------*/ 160 161 protected Map<String, Object> returnResultMap(ExecResult er) throws IOException { 162 if (!er.isSuccessful()) { 163 throw new BinaryMetadataException("There was an error executing " + "the following command: " 164 + er.getCommandLine(), er.getError()); 165 } 166 StringBuilder sb = new StringBuilder(); 167 for (String line : er.getOutput()) { 168 sb.append(line); 169 } 170 String jsonOutput = sb.toString(); 171 List<Map<String, Object>> resultList = jacksonMapper.readValue(jsonOutput, 172 new TypeReference<List<HashMap<String, Object>>>() { 173 }); 174 Map<String, Object> resultMap = resultList.get(0); 175 // Remove the SourceFile metadata injected automatically by ExifTool. 176 resultMap.remove(META_NON_USED_SOURCE_FILE); 177 parseDates(resultMap); 178 return resultMap; 179 } 180 181 /** 182 * @since 7.4 183 */ 184 protected void parseDates(Map<String, Object> resultMap) { 185 for (String prop : new String[] { EXIF_IMAGE_DATE_TIME, EXIF_PHOTO_DATE_TIME_ORIGINAL, 186 EXIF_PHOTO_DATE_TIME_DIGITIZED }) { 187 if (resultMap.containsKey(prop)) { 188 Object dateObject = resultMap.get(prop); 189 if (dateObject instanceof String) { 190 SimpleDateFormat f = new SimpleDateFormat(DATE_FORMAT_PATTERN); 191 try { 192 Date date = f.parse((String) dateObject); 193 resultMap.put(prop, date); 194 } catch (ParseException e) { 195 log.error("Could not parse property " + prop, e); 196 } 197 } 198 } 199 } 200 } 201 202 protected List<String> getCommandTags(List<String> metadataList) { 203 return metadataList.stream().map(tag -> "-" + tag).collect(Collectors.toList()); 204 } 205 206 protected List<String> getCommandTags(Map<String, Object> metadataMap) { 207 List<String> commandTags = new ArrayList<>(); 208 for (String tag : metadataMap.keySet()) { 209 Object metadataValue = metadataMap.get(tag); 210 if (metadataValue instanceof Collection) { 211 commandTags.addAll(buildCommandTagsFromCollection(tag, (Collection<Object>) metadataValue)); 212 } else if (metadataValue instanceof Object[]) { 213 commandTags.addAll(buildCommandTagsFromCollection(tag, Arrays.asList((Object[]) metadataValue))); 214 } else if (metadataValue instanceof Calendar) { 215 commandTags.add(buildCommandTagFromDate(tag, ((Calendar) metadataValue).getTime())); 216 } else { 217 commandTags.add(buildCommandTag(tag, metadataValue)); 218 } 219 } 220 return commandTags; 221 } 222 223 /** 224 * @since 8.3 225 */ 226 private String buildCommandTag(String tag, Object value) { 227 return "-" + tag + "=" + ObjectUtils.toString(value); 228 } 229 230 /** 231 * @since 8.3 232 */ 233 private List<String> buildCommandTagsFromCollection(String tag, Collection<Object> values) { 234 return values.isEmpty() ? Collections.singletonList("-" + tag + "=") : values.stream().map( 235 val -> buildCommandTag(tag, val) 236 ).collect(Collectors.toList()); 237 } 238 239 /** 240 * @since 8.4 241 */ 242 private String buildCommandTagFromDate(String tag, Date date) { 243 SimpleDateFormat formatter = new SimpleDateFormat(DATE_FORMAT_PATTERN); 244 return "-" + tag + "=" + formatter.format(date); 245 } 246 247 protected Pattern VALID_EXT = Pattern.compile("[a-zA-Z0-9]*"); 248 249 /** 250 * We don't want to rely on {@link Blob#getCloseableFile} because it may return the original and we always want a 251 * temporary one to be sure we have a clean filename to pass. 252 * 253 * @since 7.4 254 */ 255 protected CloseableFile getTemporaryFile(Blob blob) throws IOException { 256 String ext = FilenameUtils.getExtension(blob.getFilename()); 257 if (!VALID_EXT.matcher(ext).matches()) { 258 ext = "tmp"; 259 } 260 File tmp = Framework.createTempFile("nxblob-", '.' + ext); 261 File file = blob.getFile(); 262 if (file == null) { 263 // if we don't have an underlying File, use a temporary File 264 try (InputStream in = blob.getStream()) { 265 Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING); 266 } 267 } else { 268 // attempt to create a symbolic link, which would be cheaper than a copy 269 tmp.delete(); 270 try { 271 Files.createSymbolicLink(tmp.toPath(), file.toPath().toAbsolutePath()); 272 } catch (IOException | UnsupportedOperationException e) { 273 // symbolic link not supported, do a copy instead 274 Files.copy(file.toPath(), tmp.toPath()); 275 } 276 } 277 return new CloseableFile(tmp, true); 278 } 279 280 /** 281 * Gets a new blob on a temporary file which is a copy of the blob's. 282 * 283 * @since 7.4 284 */ 285 protected Blob getTemporaryBlob(Blob blob) throws IOException { 286 String ext = FilenameUtils.getExtension(blob.getFilename()); 287 if (!VALID_EXT.matcher(ext).matches()) { 288 ext = "tmp"; 289 } 290 Blob newBlob = new FileBlob('.' + ext); 291 File tmp = newBlob.getFile(); 292 File file = blob.getFile(); 293 if (file == null) { 294 try (InputStream in = blob.getStream()) { 295 Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING); 296 } 297 } else { 298 // do a copy 299 Files.copy(file.toPath(), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING); 300 } 301 return newBlob; 302 } 303 304}