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