TikaMp4SoundHandler.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 com.drew.imaging.mp4.Mp4Handler;
import com.drew.metadata.Metadata;
import com.drew.metadata.mp4.Mp4Context;
import com.drew.metadata.mp4.media.Mp4SoundHandler;

import org.apache.tika.io.EndianUtils;
import org.apache.tika.metadata.Audio;

/**
 * Extends the sound track handling with what the base handler does not read
 * from the sample description: DRM protection markers (protected sample entry
 * formats such as 'drms' or 'enca') and the average bitrate from the 'esds'
 * elementary stream descriptor. See TIKA-4779.
 */
class TikaMp4SoundHandler extends Mp4SoundHandler {

    private final org.apache.tika.metadata.Metadata tikaMetadata;

    TikaMp4SoundHandler(Metadata metadata, Mp4Context context,
                        org.apache.tika.metadata.Metadata tikaMetadata) {
        super(metadata, context);
        this.tikaMetadata = tikaMetadata;
    }

    @Override
    public Mp4Handler<?> processBox(String type, byte[] payload, long boxSize,
                                    Mp4Context context) throws IOException {
        if ("stsd".equals(type) && payload != null) {
            extractFromSampleDescriptions(payload);
        }
        return super.processBox(type, payload, boxSize, context);
    }

    private void extractFromSampleDescriptions(byte[] b) {
        Mp4SampleEntries.walk(b, this::sampleEntry);
    }

    private void sampleEntry(String fourCC, byte[] b, int start, int end) {
        int children = -1;
        if (start + Mp4SampleEntries.SAMPLE_ENTRY_FIELDS + 2 <= end) {
            //after the SampleEntry fields come the version-dependent fixed
            //sound fields, starting with the 2 byte version, then child boxes
            int version = EndianUtils.getUShortBE(b, start + Mp4SampleEntries.SAMPLE_ENTRY_FIELDS);
            children = start + soundEntrySize(version);
        }
        //protected streams replace the codec FourCC with a protected sample
        //entry format, 'drms' (FairPlay) or 'enca' (ISO common encryption),
        //and keep the original one in a child 'sinf'/'frma' box
        if (Mp4SampleEntries.isProtected(fourCC)) {
            tikaMetadata.set(Audio.HAS_DRM, true);
            if (children >= 0) {
                String original = Mp4SampleEntries.originalFormat(b, children, end);
                if (original != null) {
                    fourCC = original;
                }
            }
        }
        if (fourCC != null) {
            tikaMetadata.set(Audio.FOURCC, fourCC);
        }
        if (children >= 0) {
            int bitRate = findEsdsAverageBitRate(b, children, end, 0);
            if (bitRate > 0) {
                tikaMetadata.set(Audio.BITRATE, bitRate);
            }
        }
    }

    /**
     * Size of a sound sample entry after its box header, up to where the
     * child boxes start: 28 bytes for version 0, 44 for version 1 (four extra
     * 32-bit QuickTime fields), 64 for version 2.
     */
    private static int soundEntrySize(int version) {
        if (version == 1) {
            return 44;
        }
        if (version == 2) {
            return 64;
        }
        return 28;
    }

    //real files nest 'wave' at most one level; this only bounds crafted input,
    //where a deep chain of nested 'wave' boxes would otherwise recurse until the
    //stack overflows (an uncaught Error, not caught by Mp4Reader or CompositeParser).
    //See TIKA-4812.
    private static final int MAX_BOX_DEPTH = 10;

    /**
     * Scans the child boxes of a sample entry for an 'esds' box and returns
     * its average bitrate, or 0 if there is none. QuickTime version 1/2
     * entries may nest the 'esds' inside a 'wave' extension box.
     */
    private static int findEsdsAverageBitRate(byte[] b, int pos, int end, int depth) {
        if (depth > MAX_BOX_DEPTH) {
            return 0;
        }
        while (pos >= 0 && pos + 8 <= end) {
            long size = EndianUtils.getUIntBE(b, pos);
            if (size < 8 || size > end - pos) {
                return 0;
            }
            String type = Mp4SampleEntries.fourCC(b, pos + 4);
            if ("esds".equals(type)) {
                return readEsdsAverageBitRate(b, pos + 8, pos + (int) size);
            }
            if ("wave".equals(type)) {
                int nested = findEsdsAverageBitRate(b, pos + 8, pos + (int) size, depth + 1);
                if (nested > 0) {
                    return nested;
                }
            }
            pos += (int) size;
        }
        return 0;
    }

    /**
     * Extracts the average bitrate from an 'esds' box body, or returns 0 if
     * the descriptors cannot be walked. The chain is an ES_Descriptor (tag
     * 0x03) with three optional fields signalled by its flags byte, followed
     * by a DecoderConfigDescriptor (tag 0x04) whose fixed fields end with the
     * maximum and average bitrates.
     */
    private static int readEsdsAverageBitRate(byte[] b, int pos, int end) {
        //4 bytes version and flags, then the ES descriptor
        pos += 4;
        if (pos >= end || b[pos] != 0x03) {
            return 0;
        }
        pos = skipDescriptorLength(b, pos + 1);
        if (pos + 3 > end) {
            return 0;
        }
        //ES_ID (2 bytes), then a flags/priority byte announcing the
        //optional stream dependence, URL and OCR fields
        int flags = b[pos + 2] & 0xFF;
        pos += 3;
        if ((flags & 0x80) != 0) {
            pos += 2;
        }
        if ((flags & 0x40) != 0) {
            if (pos >= end) {
                return 0;
            }
            pos += 1 + (b[pos] & 0xFF);
        }
        if ((flags & 0x20) != 0) {
            pos += 2;
        }
        if (pos >= end || b[pos] != 0x04) {
            return 0;
        }
        pos = skipDescriptorLength(b, pos + 1);
        //object type (1), stream type (1), buffer size (3), max bitrate (4)
        pos += 9;
        if (pos + 4 > end) {
            return 0;
        }
        long averageBitRate = EndianUtils.getUIntBE(b, pos);
        return averageBitRate > 0 && averageBitRate <= Integer.MAX_VALUE
                ? (int) averageBitRate : 0;
    }

    /**
     * Skips a descriptor's variable length encoding (bytes with the high bit
     * set continue the length) and returns the position of the payload.
     */
    private static int skipDescriptorLength(byte[] b, int pos) {
        while (pos < b.length && (b[pos] & 0x80) != 0) {
            pos++;
        }
        return pos + 1;
    }
}