001/*
002 * (C) Copyright 2014-2015 Nuxeo SA (http://nuxeo.com/) and contributors.
003 *
004 * All rights reserved. This program and the accompanying materials
005 * are made available under the terms of the GNU Lesser General Public License
006 * (LGPL) version 2.1 which accompanies this distribution, and is available at
007 * http://www.gnu.org/licenses/lgpl-2.1.html
008 *
009 * This library is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
012 * Lesser General Public License for more details.
013 *
014 * Contributors:
015 *     vpasquier <vpasquier@nuxeo.com>
016 */
017package org.nuxeo.binary.metadata.internals;
018
019import java.io.File;
020import java.io.IOException;
021import java.io.InputStream;
022import java.nio.file.Files;
023import java.nio.file.StandardCopyOption;
024import java.text.ParseException;
025import java.text.SimpleDateFormat;
026import java.util.Date;
027import java.util.HashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.regex.Pattern;
031import java.util.stream.Collectors;
032
033import org.apache.commons.io.FilenameUtils;
034import org.apache.commons.lang.StringUtils;
035import org.apache.commons.logging.Log;
036import org.apache.commons.logging.LogFactory;
037import org.codehaus.jackson.map.ObjectMapper;
038import org.codehaus.jackson.type.TypeReference;
039import org.nuxeo.binary.metadata.api.BinaryMetadataConstants;
040import org.nuxeo.binary.metadata.api.BinaryMetadataException;
041import org.nuxeo.binary.metadata.api.BinaryMetadataProcessor;
042import org.nuxeo.ecm.core.api.Blob;
043import org.nuxeo.ecm.core.api.CloseableFile;
044import org.nuxeo.ecm.core.api.impl.blob.FileBlob;
045import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters;
046import org.nuxeo.ecm.platform.commandline.executor.api.CommandAvailability;
047import org.nuxeo.ecm.platform.commandline.executor.api.CommandLineExecutorService;
048import org.nuxeo.ecm.platform.commandline.executor.api.CommandNotAvailable;
049import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult;
050import org.nuxeo.runtime.api.Framework;
051
052/**
053 * @since 7.1
054 */
055public class ExifToolProcessor implements BinaryMetadataProcessor {
056
057    private static final Log log = LogFactory.getLog(ExifToolProcessor.class);
058
059    private static final String META_NON_USED_SOURCE_FILE = "SourceFile";
060
061    private static final String DATE_FORMAT_PATTERN = "yyyy:MM:dd HH:mm:ss";
062
063    private static final String EXIF_IMAGE_DATE_TIME = "EXIF:DateTime";
064
065    private static final String EXIF_PHOTO_DATE_TIME_ORIGINAL = "EXIF:DateTimeOriginal";
066
067    private static final String EXIF_PHOTO_DATE_TIME_DIGITIZED = "EXIF:DateTimeDigitized";
068
069    protected final ObjectMapper jacksonMapper;
070
071    protected final CommandLineExecutorService commandLineService;
072
073    public ExifToolProcessor() {
074        jacksonMapper = new ObjectMapper();
075        commandLineService = Framework.getLocalService(CommandLineExecutorService.class);
076    }
077
078    @Override
079    public Blob writeMetadata(Blob blob, Map<String, String> metadata, boolean ignorePrefix) {
080        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_WRITE_NOPREFIX
081                : BinaryMetadataConstants.EXIFTOOL_WRITE;
082        CommandAvailability ca = commandLineService.getCommandAvailability(command);
083        if (!ca.isAvailable()) {
084            throw new BinaryMetadataException("Command '" + command + "' is not available.");
085        }
086        if (blob == null) {
087            throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob");
088        }
089        try {
090            Blob newBlob = getTemporaryBlob(blob);
091            CmdParameters params = commandLineService.getDefaultCmdParameters();
092            params.addNamedParameter("inFilePath", newBlob.getFile());
093            params.addNamedParameter("tagList", getCommandTags(metadata));
094            ExecResult er = commandLineService.execCommand(command, params);
095            boolean success = er.isSuccessful();
096            if (!success) {
097                log.error("There was an error executing " + "the following command: " + er.getCommandLine() + ". \n"
098                        + er.getOutput().get(0));
099                return null;
100            }
101            newBlob.setMimeType(blob.getMimeType());
102            newBlob.setEncoding(blob.getEncoding());
103            newBlob.setFilename(blob.getFilename());
104            return newBlob;
105        } catch (CommandNotAvailable commandNotAvailable) {
106            throw new BinaryMetadataException("Command '" + command + "' is not available.", commandNotAvailable);
107        } catch (IOException ioException) {
108            throw new BinaryMetadataException(ioException);
109        }
110    }
111
112    protected Map<String, Object> readMetadata(String command, Blob blob, List<String> metadata, boolean ignorePrefix) {
113        CommandAvailability ca = commandLineService.getCommandAvailability(command);
114        if (!ca.isAvailable()) {
115            throw new BinaryMetadataException("Command '" + command + "' is not available.");
116        }
117        if (blob == null) {
118            throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob");
119        }
120        try {
121            ExecResult er;
122            try (CloseableFile source = getTemporaryFile(blob)) {
123                CmdParameters params = commandLineService.getDefaultCmdParameters();
124                params.addNamedParameter("inFilePath", source.getFile());
125                if (metadata != null) {
126                    params.addNamedParameter("tagList", getCommandTags(metadata));
127                }
128                er = commandLineService.execCommand(command, params);
129            }
130            return returnResultMap(er);
131        } catch (CommandNotAvailable commandNotAvailable) {
132            throw new RuntimeException("Command '" + command + "' is not available.", commandNotAvailable);
133        } catch (IOException ioException) {
134            throw new BinaryMetadataException(ioException);
135        }
136    }
137
138    @Override
139    public Map<String, Object> readMetadata(Blob blob, List<String> metadata, boolean ignorePrefix) {
140        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST_NOPREFIX
141                : BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST;
142        return readMetadata(command, blob, metadata, ignorePrefix);
143    }
144
145    @Override
146    public Map<String, Object> readMetadata(Blob blob, boolean ignorePrefix) {
147        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_NOPREFIX
148                : BinaryMetadataConstants.EXIFTOOL_READ;
149        return readMetadata(command, blob, null, ignorePrefix);
150    }
151
152    /*--------------------------- Utils ------------------------*/
153
154    protected Map<String, Object> returnResultMap(ExecResult er) throws IOException {
155        if (!er.isSuccessful()) {
156            throw new BinaryMetadataException("There was an error executing " + "the following command: "
157                    + er.getCommandLine(), er.getError());
158        }
159        StringBuilder sb = new StringBuilder();
160        for (String line : er.getOutput()) {
161            sb.append(line);
162        }
163        String jsonOutput = sb.toString();
164        List<Map<String, Object>> resultList = jacksonMapper.readValue(jsonOutput,
165                new TypeReference<List<HashMap<String, Object>>>() {
166                });
167        Map<String, Object> resultMap = resultList.get(0);
168        // Remove the SourceFile metadata injected automatically by ExifTool.
169        resultMap.remove(META_NON_USED_SOURCE_FILE);
170        parseDates(resultMap);
171        return resultMap;
172    }
173
174    /**
175     * @since 7.4
176     */
177    protected void parseDates(Map<String, Object> resultMap) {
178        for (String prop : new String[] { EXIF_IMAGE_DATE_TIME, EXIF_PHOTO_DATE_TIME_ORIGINAL,
179                EXIF_PHOTO_DATE_TIME_DIGITIZED }) {
180            if (resultMap.containsKey(prop)) {
181                Object dateObject = resultMap.get(prop);
182                if (dateObject instanceof String) {
183                    SimpleDateFormat f = new SimpleDateFormat(DATE_FORMAT_PATTERN);
184                    try {
185                        Date date = f.parse((String) dateObject);
186                        resultMap.put(prop, date);
187                    } catch (ParseException e) {
188                        log.error("Could not parse property " + prop, e);
189                    }
190                }
191            }
192        }
193    }
194
195    protected List<String> getCommandTags(List<String> metadataList) {
196        return metadataList.stream().map(tag -> "-" + tag).collect(Collectors.toList());
197    }
198
199    protected List<String> getCommandTags(Map<String, String> metadataMap) {
200        return metadataMap.entrySet().stream().map(
201                es -> "-" + es.getKey() + "=" + StringUtils.defaultString(es.getValue()) //
202        ).collect(Collectors.toList());
203    }
204
205    protected Pattern VALID_EXT = Pattern.compile("[a-zA-Z0-9]*");
206
207    /**
208     * We don't want to rely on {@link Blob#getCloseableFile} because it may return the original and we always want a
209     * temporary one to be sure we have a clean filename to pass.
210     *
211     * @since 7.4
212     */
213    protected CloseableFile getTemporaryFile(Blob blob) throws IOException {
214        String ext = FilenameUtils.getExtension(blob.getFilename());
215        if (!VALID_EXT.matcher(ext).matches()) {
216            ext = "tmp";
217        }
218        File tmp = File.createTempFile("nxblob-", '.' + ext);
219        File file = blob.getFile();
220        if (file == null) {
221            // if we don't have an underlying File, use a temporary File
222            try (InputStream in = blob.getStream()) {
223                Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
224            }
225        } else {
226            // attempt to create a symbolic link, which would be cheaper than a copy
227            tmp.delete();
228            try {
229                Files.createSymbolicLink(tmp.toPath(), file.toPath().toAbsolutePath());
230            } catch (IOException | UnsupportedOperationException e) {
231                // symbolic link not supported, do a copy instead
232                Files.copy(file.toPath(), tmp.toPath());
233            }
234        }
235        return new CloseableFile(tmp, true);
236    }
237
238    /**
239     * Gets a new blob on a temporary file which is a copy of the blob's.
240     *
241     * @since 7.4
242     */
243    protected Blob getTemporaryBlob(Blob blob) throws IOException {
244        String ext = FilenameUtils.getExtension(blob.getFilename());
245        if (!VALID_EXT.matcher(ext).matches()) {
246            ext = "tmp";
247        }
248        Blob newBlob = new FileBlob('.' + ext);
249        File tmp = newBlob.getFile();
250        File file = blob.getFile();
251        if (file == null) {
252            try (InputStream in = blob.getStream()) {
253                Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
254            }
255        } else {
256            // do a copy
257            Files.copy(file.toPath(), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
258        }
259        return newBlob;
260    }
261
262}