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