OggAudioParser.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.ogg;

import java.io.IOException;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import org.gagravarr.ogg.audio.OggAudioHeaders;
import org.gagravarr.ogg.audio.OggAudioInfoHeader;
import org.gagravarr.ogg.audio.OggAudioStatistics;
import org.gagravarr.ogg.audio.OggAudioStream;
import org.gagravarr.vorbis.VorbisComments;
import org.gagravarr.vorbis.VorbisStyleComments;
import org.xml.sax.SAXException;

import org.apache.tika.exception.TikaException;
import org.apache.tika.metadata.Audio;
import org.apache.tika.metadata.KeyPrefix;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.Property;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.XMP;
import org.apache.tika.metadata.XMPDM;
import org.apache.tika.parser.AbstractParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.audio.CoverArt;
import org.apache.tika.parser.audio.NumberAndTotal;
import org.apache.tika.sax.XHTMLContentHandler;
import org.apache.tika.utils.TikaDates;

/**
 * Parent parser for the various Ogg Audio formats, such as
 * Vorbis and Opus.
 */
public abstract class OggAudioParser extends AbstractParser {
    private static final long serialVersionUID = 5168743829615945633L;

    private static final KeyPrefix VORBIS =
            KeyPrefix.file("vorbis:", "Vorbis comment field names");

    /**
     * The Vorbis comment header vendor string (encoder library identification), also
     * captured under {@link org.apache.tika.metadata.XMP#CREATOR_TOOL}; kept under its
     * own name too since some consumers look for the raw vorbis: field.
     */
    private static final Property VORBIS_VENDOR = Property.internalText("vorbis:vendor");

    // Codec bitstream/library version string (e.g. "Theora 3.2.1"); distinct from the vendor/encoder tool under XMP#CREATOR_TOOL.
    protected static final Property CODEC_VERSION = Property.internalText("ogg:codec-version");

    /**
     * Comment holding an embedded picture (e.g. cover art) as a base64
     * encoded FLAC picture block
     */
    private static final String METADATA_BLOCK_PICTURE = "metadata_block_picture";


    /**
     * Returns the first positive integer found under the given comment keys,
     * or null if there is none.
     */
    private static Integer firstPositiveInteger(Map<String, List<String>> fields, String... keys) {
        for (String key : keys) {
            for (String value : all(fields, key)) {
                try {
                    int parsed = Integer.parseInt(value.trim());
                    if (parsed > 0) {
                        return parsed;
                    }
                } catch (NumberFormatException e) {
                    //skip unparseable values
                }
            }
        }
        return null;
    }

    protected static void extractChannelInfo(Metadata metadata, OggAudioInfoHeader info) {
        extractChannelInfo(metadata, info.getNumChannels());
    }

    protected static void extractChannelInfo(Metadata metadata, int channelCount) {
        if (channelCount == 1) {
            metadata.set(XMPDM.AUDIO_CHANNEL_TYPE, "Mono");
        } else if (channelCount == 2) {
            metadata.set(XMPDM.AUDIO_CHANNEL_TYPE, "Stereo");
        } else if (channelCount == 5) {
            metadata.set(XMPDM.AUDIO_CHANNEL_TYPE, "5.1");
        } else if (channelCount == 7) {
            metadata.set(XMPDM.AUDIO_CHANNEL_TYPE, "7.1");
        }
    }

    /**
     * @return the pictures carried in the comments; the caller emits them
     * through {@link #extractPictures(List, XHTMLContentHandler, ParseContext)},
     * {@link FlacParser} first merges them with the native PICTURE blocks so
     * one file yields one thumbnail
     */
    protected static List<PictureBlock> extractComments(Metadata metadata,
            XHTMLContentHandler xhtml, VorbisStyleComments comments)
            throws IOException, TikaException, SAXException {
        Map<String, List<String>> fields = fields(comments);
        String title = first(fields, VorbisComments.KEY_TITLE);
        String artist = first(fields, VorbisComments.KEY_ARTIST);
        String album = first(fields, VorbisComments.KEY_ALBUM);
        String trackNumber = first(fields, VorbisComments.KEY_TRACKNUMBER);
        // Get the specific known comments
        metadata.set(TikaCoreProperties.TITLE, title);
        metadata.set(TikaCoreProperties.CREATOR, artist);
        metadata.set(XMPDM.ARTIST, artist);
        metadata.set(XMPDM.ALBUM, album);
        metadata.set(XMPDM.GENRE, first(fields, VorbisComments.KEY_GENRE));
        String releaseDate = TikaDates.toMetadataStringKeepPartial(first(fields, VorbisComments.KEY_DATE));
        if (releaseDate != null) {
            metadata.set(XMPDM.RELEASE_DATE, releaseDate);
        }
        metadata.add(XMP.CREATOR_TOOL, comments.getVendor());
        metadata.add(VORBIS_VENDOR, comments.getVendor());

        //xmpDM:copyright is single-valued, so map the first comment; like
        //vendor, the raw comments also stay available under the vorbis: name
        String copyright = first(fields, "copyright");
        if (copyright != null) {
            metadata.set(XMPDM.COPYRIGHT, copyright);
        }

        for (String comment : all(fields, "comment")) {
            metadata.add(XMPDM.LOG_COMMENT, comment);
        }

        // Grab the rest just in case; the pictures become embedded
        //  documents instead, their raw base64 blocks help nobody
        Set<String> done = new HashSet<>();
        for (String key : Arrays.asList(
                VorbisComments.KEY_TITLE, VorbisComments.KEY_ARTIST,
                VorbisComments.KEY_ALBUM, VorbisComments.KEY_GENRE,
                VorbisComments.KEY_DATE, VorbisComments.KEY_TRACKNUMBER,
                "vendor", "comment", METADATA_BLOCK_PICTURE)) {
            done.add(fieldName(key));
        }
        // BAG: a Vorbis comment field can legitimately repeat.
        for (Map.Entry<String, List<String>> field : fields.entrySet()) {
            if (!done.contains(field.getKey())) {
                for (String value : field.getValue()) {
                    metadata.add(VORBIS, field.getKey(), value);
                }
            }
        }

        // Output as text too
        xhtml.element("h1", title);
        xhtml.element("p", artist);

        // Album and Track number
        if (trackNumber != null) {
            xhtml.element("p", album + ", track " + trackNumber);
            metadata.set(Audio.RAW_TRACK_NUMBER, trackNumber);
            NumberAndTotal trackNumberAndTotal = NumberAndTotal.parse(trackNumber);
            if (trackNumberAndTotal != null) {
                if (trackNumberAndTotal.number != null) {
                    metadata.set(XMPDM.TRACK_NUMBER, trackNumberAndTotal.number);
                }
                if (trackNumberAndTotal.total != null) {
                    metadata.set(Audio.TRACK_COUNT, trackNumberAndTotal.total);
                }
            }
        } else {
            xhtml.element("p", album);
        }
        for (String discValue : all(fields, "discnumber")) {
            metadata.set(Audio.RAW_DISC_NUMBER, discValue);
            NumberAndTotal discNumberAndTotal = NumberAndTotal.parse(discValue);
            if (discNumberAndTotal != null) {
                if (discNumberAndTotal.number != null) {
                    metadata.set(XMPDM.DISC_NUMBER, discNumberAndTotal.number);
                }
                if (discNumberAndTotal.total != null) {
                    metadata.set(Audio.DISC_COUNT, discNumberAndTotal.total);
                }
            }
        }
        //explicit totals win over the combined "n/total" form
        Integer trackTotal = firstPositiveInteger(fields, "tracktotal", "totaltracks");
        if (trackTotal != null) {
            metadata.set(Audio.TRACK_COUNT, trackTotal);
        }
        Integer discTotal = firstPositiveInteger(fields, "disctotal", "totaldiscs");
        if (discTotal != null) {
            metadata.set(Audio.DISC_COUNT, discTotal);
        }

        // A few other bits
        xhtml.element("p", first(fields, VorbisComments.KEY_DATE));
        for (String comment : all(fields, "comment")) {
            xhtml.element("p", comment);
        }
        xhtml.element("p", first(fields, VorbisComments.KEY_GENRE));

        // The pictures are the caller's to emit
        return parsePictures(fields);
    }

    /**
     * The comment fields keyed by lower-case name, re-read from the raw comment header:
     * vorbis-java folds names in the default locale and then strips non-ASCII, so a
     * Turkic JVM turns TITLE into "ttle" (TIKA-4921). Falls back to the library's map
     * when there is no raw header, as for comments built in code.
     */
    static Map<String, List<String>> fields(VorbisStyleComments comments) {
        Map<String, List<String>> fields = rawFields(comments);
        if (fields != null) {
            return fields;
        }
        fields = new LinkedHashMap<>();
        for (Map.Entry<String, List<String>> field : comments.getAllComments().entrySet()) {
            fields.computeIfAbsent(fieldName(field.getKey()), k -> new ArrayList<>())
                    .addAll(field.getValue());
        }
        return fields;
    }

    private static Map<String, List<String>> rawFields(VorbisStyleComments comments) {
        byte[] data;
        try {
            data = comments.getData();
        } catch (NullPointerException e) {
            // no packet behind comments built in code
            return null;
        }
        if (data == null) {
            return null;
        }
        // the format-specific header size is protected; find the vendor string instead
        String vendor = comments.getVendor() == null ? "" : comments.getVendor();
        byte[] vendorBytes = vendor.getBytes(StandardCharsets.UTF_8);
        ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
        int offset = -1;
        for (int headerSize : new int[]{0, 4, 7, 8}) {
            int end = headerSize + 4 + vendorBytes.length;
            if (end <= data.length && buffer.getInt(headerSize) == vendorBytes.length &&
                    Arrays.equals(data, headerSize + 4, end, vendorBytes, 0, vendorBytes.length)) {
                offset = end;
                break;
            }
        }
        if (offset < 0) {
            return null;
        }
        try {
            buffer.position(offset);
            int count = buffer.getInt();
            if (count < 0) {
                return null;
            }
            Map<String, List<String>> fields = new LinkedHashMap<>();
            for (int i = 0; i < count; i++) {
                int length = buffer.getInt();
                if (length < 0 || length > buffer.remaining()) {
                    return null;
                }
                byte[] bytes = new byte[length];
                buffer.get(bytes);
                String comment = new String(bytes, StandardCharsets.UTF_8);
                int eq = comment.indexOf('=');
                if (eq < 0) {
                    continue;
                }
                fields.computeIfAbsent(fieldName(comment.substring(0, eq)), k -> new ArrayList<>())
                        .add(comment.substring(eq + 1));
            }
            return fields;
        } catch (BufferUnderflowException e) {
            return null;
        }
    }

    private static String fieldName(String name) {
        return name.toLowerCase(Locale.ROOT);
    }

    private static String first(Map<String, List<String>> fields, String name) {
        List<String> values = fields.get(fieldName(name));
        return values == null || values.isEmpty() ? null : values.get(0);
    }

    private static List<String> all(Map<String, List<String>> fields, String name) {
        return fields.getOrDefault(fieldName(name), Collections.emptyList());
    }

    /**
     * Parses the embedded pictures, such as cover art, out of the comments.
     * The pictures are carried as base64 encoded FLAC picture blocks;
     * malformed blocks are skipped silently.
     */
    private static List<PictureBlock> parsePictures(Map<String, List<String>> fields) {
        List<PictureBlock> pictures = new ArrayList<>();
        for (String block : all(fields, METADATA_BLOCK_PICTURE)) {
            byte[] decoded;
            try {
                decoded = Base64.getMimeDecoder().decode(block);
            } catch (IllegalArgumentException e) {
                //not valid base64, skip
                continue;
            }
            PictureBlock picture = PictureBlock.parse(decoded);
            if (picture != null) {
                pictures.add(picture);
            }
        }
        return pictures;
    }

    /**
     * Sends parsed picture blocks to the embedded document extractor;
     * {@link CoverArt#thumbnailIndex(List)} decides which of them is the
     * file's thumbnail. Native FLAC PICTURE metadata blocks use the very
     * same structure, so {@link FlacParser} shares this method.
     */
    static void extractPictures(List<PictureBlock> pictures, XHTMLContentHandler xhtml,
            ParseContext context) throws IOException, SAXException {
        List<CoverArt.Picture> mapped = new ArrayList<>();
        for (PictureBlock picture : pictures) {
            mapped.add(new CoverArt.Picture(picture.pictureType, picture.mimeType,
                    picture.description, picture.data));
        }
        CoverArt.extractPictures(mapped, xhtml, context);
    }

    /**
     * A FLAC picture block: a 32 bit BE picture type, the mime type, the
     * description, the image geometry and the picture data, with mime type,
     * description and data length prefixed.
     */
    static final class PictureBlock {
        final int pictureType;
        final String mimeType;
        final String description;
        final byte[] data;

        private PictureBlock(int pictureType, String mimeType, String description, byte[] data) {
            this.pictureType = pictureType;
            this.mimeType = mimeType;
            this.description = description;
            this.data = data;
        }

        /**
         * Parses a picture block, or returns null for a malformed or
         * truncated one, or one that links to a picture instead of
         * embedding it.
         */
        static PictureBlock parse(byte[] block) {
            try {
                ByteBuffer buffer = ByteBuffer.wrap(block);
                int pictureType = buffer.getInt();
                String mimeType = getPrefixedString(buffer, StandardCharsets.ISO_8859_1);
                if (mimeType == null || "-->".equals(mimeType)) {
                    return null;
                }
                String description = getPrefixedString(buffer, StandardCharsets.UTF_8);
                if (description == null) {
                    return null;
                }
                // Width, height, color depth and number of colors
                buffer.position(buffer.position() + 16);
                int dataLength = buffer.getInt();
                if (dataLength <= 0 || dataLength > buffer.remaining()) {
                    return null;
                }
                byte[] data = new byte[dataLength];
                buffer.get(data);
                return new PictureBlock(pictureType, mimeType, description, data);
            } catch (BufferUnderflowException | IllegalArgumentException e) {
                return null;
            }
        }
    }

    private static String getPrefixedString(ByteBuffer buffer, Charset charset) {
        int length = buffer.getInt();
        if (length < 0 || length > buffer.remaining()) {
            return null;
        }
        byte[] bytes = new byte[length];
        buffer.get(bytes);
        return new String(bytes, charset);
    }

    protected static void extractDuration(Metadata metadata, XHTMLContentHandler xhtml,
            OggAudioHeaders headers, OggAudioStream audio) throws IOException, SAXException {
        // Have the statistics calculated
        OggAudioStatistics stats = new OggAudioStatistics(headers, audio);
        stats.calculate();

        // Record the duration, if available
        extractDuration(metadata, xhtml, stats.getDurationSeconds());
    }

    protected static void extractDuration(Metadata metadata, XHTMLContentHandler xhtml,
            double duration) throws SAXException {
        // Record the duration, if available
        if (duration > 0) {
            // Save as metadata to the nearest .01 seconds.
            // DecimalFormat is not thread-safe and these parsers are shared across
            // threads, so create a new one per call (see MP4Parser).
            DecimalFormat durationFormat =
                    (DecimalFormat) NumberFormat.getNumberInstance(Locale.ROOT);
            durationFormat.applyPattern("0.0#");
            metadata.add(XMPDM.DURATION, durationFormat.format(duration));

            // Output as Hours / Minutes / Seconds / Parts
            String durationStr = formatDuration(duration);
            xhtml.element("p", durationStr);
        }
    }

    private static String formatDuration(double durationSeconds) {
        long totalSeconds = (long) durationSeconds;
        long hours = totalSeconds / 3600;
        long minutes = (totalSeconds % 3600) / 60;
        long seconds = totalSeconds % 60;
        double fraction = durationSeconds - totalSeconds;

        if (hours > 0) {
            return String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds);
        } else {
            return String.format(Locale.ROOT, "%d:%02d", minutes, seconds);
        }
    }

}