MP4Parser.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.tika.parser.mp4;

import java.io.IOException;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import com.drew.imaging.mp4.Mp4Reader;
import com.drew.metadata.Directory;
import com.drew.metadata.MetadataException;
import com.drew.metadata.mp4.Mp4BoxHandler;
import com.drew.metadata.mp4.Mp4Directory;
import com.drew.metadata.mp4.media.Mp4SoundDirectory;
import com.drew.metadata.mp4.media.Mp4VideoDirectory;
import org.xml.sax.ContentHandler;
import org.xml.sax.SAXException;

import org.apache.tika.config.TikaComponent;
import org.apache.tika.exception.RuntimeSAXException;
import org.apache.tika.exception.TikaException;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.Property;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.XMPDM;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.sax.XHTMLContentHandler;
import org.apache.tika.utils.StringUtils;

/**
 * Parser for the MP4 media container format, as well as the older
 * QuickTime format that MP4 is based on.
 * <p>
 * This uses Drew Noakes' metadata-extractor: https://github.com/drewnoakes/metadata-extractor
 */
@TikaComponent(name = "mp4-parser")
public class MP4Parser implements Parser {
    /**
     * Serial version UID
     */
    private static final long serialVersionUID = 84011216792285L;
    private static final Map<MediaType, List<String>> typesMap = new HashMap<>();
    private static final Set<MediaType> SUPPORTED_TYPES =
            Collections.unmodifiableSet(typesMap.keySet());

    private static final MediaType APPLICATION_MP4 = MediaType.application("mp4");
    private static final MediaType AUDIO_MP4 = MediaType.audio("mp4");

    private static final int MAX_ERROR_MESSAGES = 100;
    static {
        // All types should be 4 bytes long, space padded as needed
        typesMap.put(MediaType.audio("mp4"), Arrays.asList("M4A ", "M4B ", "F4A ", "F4B "));
        typesMap.put(MediaType.video("3gpp"),
                Arrays.asList("3ge6", "3ge7", "3gg6", "3gp1", "3gp2", "3gp3", "3gp4", "3gp5",
                        "3gp6", "3gs7"));
        typesMap.put(MediaType.video("3gpp2"), Arrays.asList("3g2a", "3g2b", "3g2c"));
        typesMap.put(MediaType.video("mp4"), Arrays.asList("mp41", "mp42"));
        typesMap.put(MediaType.video("x-m4v"), Arrays.asList("M4V ", "M4VH", "M4VP"));

        typesMap.put(MediaType.video("quicktime"), Collections.emptyList());
        typesMap.put(MediaType.application("mp4"), Collections.emptyList());
    }

    public Set<MediaType> getSupportedTypes(ParseContext context) {
        return SUPPORTED_TYPES;
    }

    public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                      ParseContext context) throws IOException, SAXException, TikaException {


        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
        xhtml.startDocument();
        com.drew.metadata.Metadata mp4Metadata = new com.drew.metadata.Metadata();
        Mp4BoxHandler boxHandler = new TikaMp4BoxHandler(mp4Metadata, metadata, xhtml);
        //we used to spool to disk and then read from that with sannies parser.
        //we think that drewnoakes' parser streams the data so we don't need to spool
        try {
            Mp4Reader.extract(tis, boxHandler);
        } catch (RuntimeSAXException e) {
            throw (SAXException) e.getCause();
        }
        //TODO -- figure out how to get IOExceptions out of boxhandler. Mp4Reader
        //currently swallows IOExceptions.
        final Collection<Mp4Directory> mp4Directories = mp4Metadata.getDirectoriesOfType(Mp4Directory.class);
        final Set<String> errorMessages = processMp4Directories(mp4Directories, metadata);

        // Despite the brand, if we ONLY have audio streams with no video
        if (isAudioOnly(mp4Directories)) {
            // Mark this as audio/mp4
            metadata.set(Metadata.CONTENT_TYPE, AUDIO_MP4.toString());
        }

        for (String m : errorMessages) {
            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, m);
        }
        xhtml.endDocument();
    }

    private Set<String> processMp4Directories(Collection<Mp4Directory> mp4Directories,
                                         Metadata metadata) {
        Set<String> errorMsgs = new HashSet<>();
        for (Mp4Directory mp4Directory : mp4Directories) {
            for (String m : mp4Directory.getErrors()) {
                if (errorMsgs.size() < MAX_ERROR_MESSAGES) {
                    errorMsgs.add(m);
                } else {
                    break;
                }
            }
/*            for (Tag t : mp4Directory.getTags()) {
                System.out.println(mp4Directory.getClass() + " : " + t.getTagName()
                                + " : " + mp4Directory.getString(t.getTagType()));
            }*/
            if (mp4Directory instanceof Mp4SoundDirectory) {
                processMp4SoundDirectory((Mp4SoundDirectory) mp4Directory, metadata);
            } else if (mp4Directory instanceof Mp4VideoDirectory) {
                processMp4VideoDirectory((Mp4VideoDirectory) mp4Directory, metadata);
            } else {
                processActualMp4Directory(mp4Directory, metadata);
            }
        }
        return errorMsgs;
    }

    private void processMp4VideoDirectory(Mp4VideoDirectory mp4Directory, Metadata metadata) {
        addInt(mp4Directory, metadata, Mp4VideoDirectory.TAG_HEIGHT, Metadata.IMAGE_LENGTH);
        addInt(mp4Directory, metadata, Mp4VideoDirectory.TAG_WIDTH, Metadata.IMAGE_WIDTH);
        if (mp4Directory.containsTag(Mp4VideoDirectory.TAG_COMPRESSOR_NAME)) {
            String compressor = mp4Directory.getString(Mp4VideoDirectory.TAG_COMPRESSOR_NAME);
            metadata.set(XMPDM.VIDEO_COMPRESSOR, compressor);
        }
    }

    /**
     * Check we have only audio with no video metadata.
     * <p>
     * Other non-video metadata can exist - as long as there's at least one {@link Mp4SoundDirectory}.
     *
     * @param directories from MP4 file
     * @return whether we can classify the file audio/mp4
     */
    static boolean isAudioOnly(final Collection<Mp4Directory> directories) {
        boolean containsSound = false;

        for (final Mp4Directory directory : directories) {
            if (directory instanceof Mp4VideoDirectory) {
                // Fail fast as this isn't audio only
                return false;
            }
            if (directory instanceof Mp4SoundDirectory) {
                containsSound = true;
            }
        }

        return containsSound;
    }

    private void processMp4SoundDirectory(Mp4SoundDirectory mp4SoundDirectory,
                                        Metadata metadata) {
        addInt(mp4SoundDirectory, metadata, Mp4SoundDirectory.TAG_AUDIO_SAMPLE_RATE,
                XMPDM.AUDIO_SAMPLE_RATE);

        try {
            int numChannels = mp4SoundDirectory.getInt(Mp4SoundDirectory.TAG_NUMBER_OF_CHANNELS);

            if (numChannels == 1) {
                metadata.set(XMPDM.AUDIO_CHANNEL_TYPE, "Mono");
            } else if (numChannels == 2) {
                metadata.set(XMPDM.AUDIO_CHANNEL_TYPE, "Stereo");
            } else {
                //??? log
            }

        } catch (MetadataException e) {
            //log
        }
    }

    private void addInt(Mp4Directory mp4Directory, Metadata metadata, int tag,
                        Property property) {
        try {
            int val = mp4Directory.getInt(tag);
            metadata.set(property, val);
        } catch (MetadataException e) {
            //log
        }
    }

    private void processActualMp4Directory(Mp4Directory mp4Directory, Metadata metadata) {
        addDate(mp4Directory, metadata, Mp4Directory.TAG_CREATION_TIME, TikaCoreProperties.CREATED);
        addDate(mp4Directory, metadata, Mp4Directory.TAG_MODIFICATION_TIME,
                TikaCoreProperties.MODIFIED);
        handleBrands(mp4Directory, metadata);
        handleDurationInSeconds(mp4Directory, metadata);

        addDouble(mp4Directory, metadata, Mp4Directory.TAG_LATITUDE, TikaCoreProperties.LATITUDE);
        addDouble(mp4Directory, metadata, Mp4Directory.TAG_LONGITUDE, TikaCoreProperties.LONGITUDE);
        addInt(mp4Directory, metadata, Mp4Directory.TAG_TIME_SCALE, XMPDM.AUDIO_SAMPLE_RATE);
    }

    private void handleDurationInSeconds(Mp4Directory mp4Directory, Metadata metadata) {
        String durationInSeconds = mp4Directory.getString(Mp4Directory.TAG_DURATION_SECONDS);
        if (durationInSeconds == null) {
            return;
        }
        if (! durationInSeconds.contains("/")) {
            try {
                double d = Double.parseDouble(durationInSeconds);
                DecimalFormat df =
                        (DecimalFormat) NumberFormat.getNumberInstance(Locale.ROOT);
                df.applyPattern("0.0#");
                metadata.set(XMPDM.DURATION, df.format(d));
            } catch (NumberFormatException e) {
                //swallow
            }
            return;
        }
        String[] bits = durationInSeconds.split("/");
        if (bits.length != 2) {
            return;
        }
        double durationSeconds;
        try {
            long numerator = Long.parseLong(bits[0]);
            long denominator = Long.parseLong(bits[1]);
            if (denominator != 0) {
                durationSeconds = (double) numerator / (double) denominator;
                // Get the duration
                //TODO Replace this with a 2dp Duration Property Converter
                //avoid thread safety issues by creating a new decimal format for every call
                //threadlocal doesn't play well in long running processes.
                DecimalFormat df =
                        (DecimalFormat) NumberFormat.getNumberInstance(Locale.ROOT);
                df.applyPattern("0.0#");
                metadata.set(XMPDM.DURATION, df.format(durationSeconds));
            }
        } catch (NumberFormatException e) {
            //log
            return;
        }
    }

    private void handleBrands(Mp4Directory mp4Directory, Metadata metadata) {


        String majorBrand = mp4Directory.getString(Mp4Directory.TAG_MAJOR_BRAND);
        // Identify the type based on the major brand
        Optional<MediaType> typeHolder = typesMap.entrySet().stream()
                .filter(e -> e.getValue().contains(majorBrand)).findFirst()
                .map(Map.Entry::getKey);

        if (!typeHolder.isPresent()) {
            String compatibleBrands =
                    mp4Directory.getString(Mp4Directory.TAG_COMPATIBLE_BRANDS);
            if (compatibleBrands != null) {
                // If no match for major brand, see if any of the compatible brands match
                typeHolder = typesMap.entrySet().stream().filter(e ->
                        e.getValue().stream().anyMatch(compatibleBrands::contains))
                        .findFirst().map(Map.Entry::getKey);
            }
        }
        MediaType type = typeHolder.orElse(MediaType.application("mp4"));
        if (metadata.getValues(Metadata.CONTENT_TYPE) == null) {
            metadata.set(Metadata.CONTENT_TYPE, type.toString());
        } else if (! type.equals(APPLICATION_MP4)) { //todo check for specialization?
            metadata.set(Metadata.CONTENT_TYPE, type.toString());
        }
        if (type.getType().equals("audio") && ! StringUtils.isBlank(majorBrand)) {
            metadata.set(XMPDM.AUDIO_COMPRESSOR, majorBrand.trim());
        }

    }

    private void addDate(Mp4Directory mp4Directory, Metadata metadata, int tag,
                         Property property) {
        Date d = mp4Directory.getDate(tag);
        if (d == null) {
            return;
        }
        metadata.set(property, d);

    }

    private void addDouble(Directory mp4Directory, Metadata metadata, int tag,
                           Property property) {
        try {
            double val = mp4Directory.getDouble(tag);
            metadata.set(property, val);
        } catch (MetadataException e) {
            //log
            return;
        }

    }
}