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 java.nio.charset.StandardCharsets;

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);
    }

    /**
     * Walks the sample description entries: 4 bytes version and flags, a
     * 4 byte entry count, then one sample entry per count, each starting with
     * its own size and format fourcc.
     */
    private void extractFromSampleDescriptions(byte[] b) {
        if (b.length < 8) {
            return;
        }
        long entryCount = EndianUtils.getUIntBE(b, 4);
        int pos = 8;
        for (long i = 0; i < entryCount && pos + 8 <= b.length; i++) {
            long size = EndianUtils.getUIntBE(b, pos);
            if (size < 16 || size > b.length - pos) {
                break;
            }
            int end = pos + (int) size;
            String format = fourCc(b, pos + 4);
            //protected streams replace the codec fourcc with a protected
            //entry format: 'drms' (FairPlay) or 'enca' (ISO common encryption)
            if ("drms".equals(format) || "enca".equals(format)) {
                tikaMetadata.set(Audio.HAS_DRM, true);
            }
            if (pos + 18 <= end) {
                //sample entry: 8 byte header, 6 reserved, 2 data ref index,
                //then version-dependent fixed sound fields before child boxes
                int version = EndianUtils.getUShortBE(b, pos + 16);
                int bitRate = findEsdsAverageBitRate(b, pos + soundEntrySize(version), end);
                if (bitRate > 0) {
                    tikaMetadata.set(Audio.BITRATE, bitRate);
                }
            }
            pos = end;
        }
    }

    /**
     * Size of the fixed part of a sound sample entry, after which the child
     * boxes start: 36 bytes for version 0, 52 for version 1 (four extra
     * 32-bit QuickTime fields), 72 for version 2.
     */
    private static int soundEntrySize(int version) {
        if (version == 1) {
            return 52;
        }
        if (version == 2) {
            return 72;
        }
        return 36;
    }

    /**
     * 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) {
        while (pos >= 0 && pos + 8 <= end) {
            long size = EndianUtils.getUIntBE(b, pos);
            if (size < 8 || size > end - pos) {
                return 0;
            }
            String type = 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);
                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;
    }

    private static String fourCc(byte[] b, int pos) {
        return new String(b, pos, 4, StandardCharsets.ISO_8859_1);
    }
}