This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new cca14779ab TIKA-4850: Emit audio cover art as a THUMBNAIL embedded 
document (#3090)
cca14779ab is described below

commit cca14779aba75ea1a5a985a6b47dc00228983921
Author: Dominik Schmidt <[email protected]>
AuthorDate: Tue Sep 1 13:01:27 2026 +0200

    TIKA-4850: Emit audio cover art as a THUMBNAIL embedded document (#3090)
---
 CHANGES.txt                                        |   8 ++
 .../org/apache/tika/parser/audio/CoverArt.java     | 155 ++++++++++++++++++++
 .../java/org/apache/tika/parser/mp3/Mp3Parser.java |  36 ++---
 .../apache/tika/parser/mp4/TikaMp4BoxHandler.java  |   5 +-
 .../tika/parser/mp4/boxes/TikaUserDataBox.java     |  27 +++-
 .../org/apache/tika/parser/ogg/FlacParser.java     |  42 +++---
 .../org/apache/tika/parser/ogg/OggAudioParser.java | 158 ++++++++++-----------
 .../org/apache/tika/parser/ogg/OpusParser.java     |   3 +-
 .../org/apache/tika/parser/ogg/SpeexParser.java    |   3 +-
 .../org/apache/tika/parser/ogg/TheoraParser.java   |   4 +-
 .../org/apache/tika/parser/ogg/VorbisParser.java   |   3 +-
 .../org/apache/tika/parser/audio/CoverArtTest.java |  59 ++++++++
 .../org/apache/tika/parser/mp3/Mp3ParserTest.java  |  72 +++++++++-
 .../org/apache/tika/parser/mp4/MP4ParserTest.java  |  46 +++++-
 .../org/apache/tika/parser/ogg/FlacParserTest.java |  85 ++++++++++-
 .../apache/tika/parser/ogg/OggAudioParserTest.java |  72 +++++++---
 .../apache/tika/parser/ogg/VorbisParserTest.java   |   6 +-
 .../testFLAC_commentAndNativePicture.flac          | Bin 0 -> 10833 bytes
 18 files changed, 625 insertions(+), 159 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 664e3ebb98..572e357624 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
 Release 4.1.0 - unreleased
 
+   * Audio cover art is emitted as a THUMBNAIL embedded document, like the
+     preview image of the document container formats: the front cover (ID3
+     APIC and FLAC/Vorbis picture type 3), else the first picture of type
+     "Other" or unknown, else the first picture, and the first covr image
+     of an MP4. Further pictures
+     stay INLINE. Clients that looked for cover art as INLINE need to
+     accept THUMBNAIL as well (TIKA-4850).
+
    * tika-grpc resolves its plugin-roots fallback against the install
      layout via DefaultPluginsDir instead of a working-directory-relative
      pf4j default, and a WARN names the resolved directory when no plugins
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/audio/CoverArt.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/audio/CoverArt.java
new file mode 100644
index 0000000000..ebdc8620c2
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/audio/CoverArt.java
@@ -0,0 +1,155 @@
+/*
+ * 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.audio;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.xml.sax.SAXException;
+
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.mp3.ID3Tags;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Picks the picture that stands for an audio file among its embedded
+ * pictures and sends them all to the embedded document extractor. That
+ * picture is emitted as a
+ * {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}, like the
+ * preview image of the document container formats, so a client can find
+ * the representative image of any file the same way; the other pictures
+ * are {@link TikaCoreProperties.EmbeddedResourceType#INLINE}. See TIKA-4850.
+ */
+public final class CoverArt {
+
+    /**
+     * The ID3v2 APIC picture type of the front cover, shared by the FLAC
+     * and Vorbis picture blocks.
+     */
+    public static final int FRONT_COVER = 3;
+
+    /**
+     * The ID3v2 APIC picture type "Other". Many taggers store the main
+     * cover art with this type instead of marking it a front cover.
+     */
+    public static final int OTHER = 0;
+
+    private CoverArt() {
+    }
+
+    /**
+     * One embedded picture of an audio file. The type is the ID3v2 APIC
+     * picture type, shared by the FLAC and Vorbis picture blocks; mime type
+     * and description may be null or empty.
+     */
+    public record Picture(int type, String mimeType, String description, 
byte[] data) {
+
+        /**
+         * The type as {@link #thumbnailIndex(List)} sees it: a value beyond
+         * the ID3 picture type table counts as unknown.
+         */
+        int normalizedType() {
+            return type >= ID3Tags.PICTURE_TYPES.length ? -1 : type;
+        }
+    }
+
+    /**
+     * Returns the index of the picture to mark as the thumbnail: the first
+     * front cover; else the first picture whose type is "Other" or unknown,
+     * which is where taggers put the main art when they do not classify it,
+     * rather than e.g. a back cover or a leaflet that happens to come first;
+     * else the first picture.
+     *
+     * @param pictureTypes the picture types in file order; a negative value
+     *                     for a picture whose type is unknown
+     * @return the index, or -1 if there are no pictures
+     */
+    public static int thumbnailIndex(List<Integer> pictureTypes) {
+        if (pictureTypes.isEmpty()) {
+            return -1;
+        }
+        int front = pictureTypes.indexOf(FRONT_COVER);
+        if (front >= 0) {
+            return front;
+        }
+        for (int i = 0; i < pictureTypes.size(); i++) {
+            if (pictureTypes.get(i) <= OTHER) {
+                return i;
+            }
+        }
+        return 0;
+    }
+
+    /**
+     * The resource type of the picture at the given index.
+     */
+    public static TikaCoreProperties.EmbeddedResourceType resourceType(int 
index,
+                                                                       int 
thumbnailIndex) {
+        return index == thumbnailIndex ? 
TikaCoreProperties.EmbeddedResourceType.THUMBNAIL
+                : TikaCoreProperties.EmbeddedResourceType.INLINE;
+    }
+
+    /**
+     * Sends the pictures of one audio file to the embedded document
+     * extractor: the one {@link #thumbnailIndex(List)} picks as the
+     * THUMBNAIL, the others as INLINE pictures. The pictures only become
+     * embedded documents, no metadata is recorded on the audio document
+     * itself. Call once per file, with all of its pictures, so exactly one
+     * of them is the thumbnail.
+     */
+    public static void extractPictures(List<Picture> pictures, 
XHTMLContentHandler xhtml,
+                                       ParseContext context) throws 
IOException, SAXException {
+        if (pictures.isEmpty()) {
+            return;
+        }
+        List<Integer> pictureTypes = new ArrayList<>();
+        for (Picture picture : pictures) {
+            pictureTypes.add(picture.normalizedType());
+        }
+        int thumbnailIndex = thumbnailIndex(pictureTypes);
+        EmbeddedDocumentExtractor extractor =
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+        for (int i = 0; i < pictures.size(); i++) {
+            Picture picture = pictures.get(i);
+            Metadata pictureMetadata = Metadata.newInstance(context);
+            pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                    resourceType(i, thumbnailIndex).name());
+            if (picture.mimeType() != null && !picture.mimeType().isEmpty()) {
+                pictureMetadata.set(HttpHeaders.CONTENT_TYPE, 
picture.mimeType());
+            }
+            if (picture.description() != null && 
!picture.description().isEmpty()) {
+                pictureMetadata.set(TikaCoreProperties.TITLE, 
picture.description());
+            }
+            if (picture.type() >= 0 && picture.type() < 
ID3Tags.PICTURE_TYPES.length) {
+                pictureMetadata.set(TikaCoreProperties.DESCRIPTION,
+                        ID3Tags.PICTURE_TYPES[picture.type()]);
+            }
+            if (extractor.shouldParseEmbedded(pictureMetadata, context)) {
+                try (TikaInputStream pictureStream = 
TikaInputStream.get(picture.data())) {
+                    extractor.parseEmbedded(pictureStream, xhtml, 
pictureMetadata, context, true);
+                }
+            }
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java
index 3c34e0f775..b55a41ae6d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java
@@ -28,8 +28,6 @@ import org.xml.sax.SAXException;
 
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
-import org.apache.tika.extractor.EmbeddedDocumentExtractor;
-import org.apache.tika.extractor.EmbeddedDocumentUtil;
 import org.apache.tika.io.TailStream;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Audio;
@@ -41,6 +39,7 @@ 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.parser.audio.CoverArt;
 import org.apache.tika.parser.audio.NumberAndTotal;
 import org.apache.tika.parser.mp3.ID3Tags.ID3Comment;
 import org.apache.tika.parser.mp3.ID3Tags.ID3Picture;
@@ -308,40 +307,21 @@ public class Mp3Parser implements Parser {
 
     /**
      * Sends the embedded pictures, such as cover art, from the ID3v2 tags
-     * to the embedded document extractor. The pictures only become embedded
-     * documents, no metadata is recorded on the audio document itself.
+     * to the embedded document extractor;
+     * {@link CoverArt#thumbnailIndex(java.util.List)} decides which of them
+     * is the file's thumbnail.
      */
     private static void extractPictures(ID3Tags[] tags, XHTMLContentHandler 
xhtml,
                                         ParseContext context)
             throws IOException, SAXException {
-        EmbeddedDocumentExtractor extractor = null;
+        List<CoverArt.Picture> pictures = new ArrayList<>();
         for (ID3Tags tag : tags) {
             for (ID3Picture picture : tag.getPictures()) {
-                if (extractor == null) {
-                    extractor = 
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
-                }
-                Metadata pictureMetadata = Metadata.newInstance(context);
-                pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
-                        
TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
-                if (picture.getMimeType() != null) {
-                    pictureMetadata.set(HttpHeaders.CONTENT_TYPE, 
picture.getMimeType());
-                }
-                if (picture.getDescription() != null && 
!picture.getDescription().isEmpty()) {
-                    pictureMetadata.set(TikaCoreProperties.TITLE, 
picture.getDescription());
-                }
-                if (picture.getPictureType() >= 0 &&
-                        picture.getPictureType() < 
ID3Tags.PICTURE_TYPES.length) {
-                    pictureMetadata.set(TikaCoreProperties.DESCRIPTION,
-                            ID3Tags.PICTURE_TYPES[picture.getPictureType()]);
-                }
-                if (extractor.shouldParseEmbedded(pictureMetadata, context)) {
-                    try (TikaInputStream pictureStream = 
TikaInputStream.get(picture.getData())) {
-                        extractor.parseEmbedded(pictureStream, xhtml, 
pictureMetadata, context,
-                                true);
-                    }
-                }
+                pictures.add(new CoverArt.Picture(picture.getPictureType(),
+                        picture.getMimeType(), picture.getDescription(), 
picture.getData()));
             }
         }
+        CoverArt.extractPictures(pictures, xhtml, context);
     }
 
     /**
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
index 4f3b55cf87..9230272719 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
@@ -23,6 +23,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import com.drew.imaging.mp4.Mp4Handler;
 import com.drew.lang.annotations.NotNull;
@@ -68,6 +69,8 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
     //duration of the current track's leading empty edit(s) ('elst' entries
     //with media time -1), in movie timescale units; -1 if the track has none
     private long emptyEditDuration = -1;
+    //cover images emitted so far, across all udta boxes of the file
+    private final AtomicInteger coverCount = new AtomicInteger();
 
     public TikaMp4BoxHandler(Metadata metadata, 
org.apache.tika.metadata.Metadata tikaMetadata,
                              XHTMLContentHandler xhtml, ParseContext 
parseContext) {
@@ -148,7 +151,7 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
             return this;
         }
         try {
-            new TikaUserDataBox(box, payload, tikaMetadata, xhtml, 
parseContext)
+            new TikaUserDataBox(box, payload, tikaMetadata, xhtml, 
parseContext, coverCount)
                     .addMetadata(directory);
         } catch (SAXException e) {
             throw new IOException(e);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
index e61b4e1de2..d63b4edeae 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
@@ -18,6 +18,7 @@ package org.apache.tika.parser.mp4.boxes;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import com.drew.lang.SequentialByteArrayReader;
 import com.drew.lang.SequentialReader;
@@ -52,15 +53,30 @@ public class TikaUserDataBox {
     private String coordinateString;
 
     private boolean isQuickTime = false;
+    //covr carries no picture type, so the first cover of the file is the
+    //thumbnail; the count is shared across the file's udta boxes
+    private final AtomicInteger coverCount;
     private final Metadata metadata;
     private final XHTMLContentHandler xhtml;
     private final ParseContext parseContext;
     public TikaUserDataBox(@NotNull String box, byte[] payload, Metadata 
metadata,
                            XHTMLContentHandler xhtml, ParseContext 
parseContext)
             throws IOException, SAXException {
+        this(box, payload, metadata, xhtml, parseContext, new AtomicInteger());
+    }
+
+    /**
+     * @param coverCount the number of cover images already emitted for the
+     *                   file, shared across its udta boxes
+     */
+    public TikaUserDataBox(@NotNull String box, byte[] payload, Metadata 
metadata,
+                           XHTMLContentHandler xhtml, ParseContext 
parseContext,
+                           AtomicInteger coverCount)
+            throws IOException, SAXException {
         this.metadata = metadata;
         this.xhtml = xhtml;
         this.parseContext = parseContext;
+        this.coverCount = coverCount;
         int length = payload.length;
         SequentialReader reader = new SequentialByteArrayReader(payload);
         while (reader.getPosition() < (long) length) {
@@ -228,16 +244,19 @@ public class TikaUserDataBox {
 
 
     /**
-     * Sends one embedded cover image to the embedded document extractor.
-     * The image only becomes an embedded document, no metadata is recorded
-     * on the audio document itself.
+     * Sends one embedded cover image to the embedded document extractor:
+     * the first as the file's thumbnail, any further one as an inline
+     * picture. The image only becomes an embedded document, no metadata is
+     * recorded on the audio document itself.
      */
     private void handleCoverArt(SequentialReader reader, long valueType, int 
length)
             throws IOException {
         byte[] picture = reader.getBytes(length);
         Metadata pictureMetadata = Metadata.newInstance(parseContext);
         pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
-                TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
+                (coverCount.getAndIncrement() == 0
+                        ? TikaCoreProperties.EmbeddedResourceType.THUMBNAIL
+                        : 
TikaCoreProperties.EmbeddedResourceType.INLINE).name());
         //the data atom's well-known value type declares the image format;
         //for any other type leave the content type for auto-detection
         if (valueType == 13) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/FlacParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/FlacParser.java
index 0fca37e69a..6af228efd7 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/FlacParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/FlacParser.java
@@ -22,6 +22,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
@@ -36,8 +37,6 @@ import org.xml.sax.SAXException;
 
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
-import org.apache.tika.extractor.EmbeddedDocumentExtractor;
-import org.apache.tika.extractor.EmbeddedDocumentUtil;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -101,15 +100,18 @@ public class FlacParser extends AbstractParser {
             metadata.set(HttpHeaders.CONTENT_TYPE, NATIVE_FLAC.toString());
         }
 
-        // Extract any Vorbis-style comments
-        OggAudioParser.extractComments(metadata, xhtml, flac.getTags(), 
context);
+        // Extract any Vorbis-style comments; their pictures are emitted
+        //  below, together with any native PICTURE blocks, so one file
+        //  yields exactly one thumbnail
+        List<OggAudioParser.PictureBlock> pictures =
+                OggAudioParser.extractComments(metadata, xhtml, 
flac.getTags());
 
-        // Extract any embedded pictures, such as cover art, from native
-        //  FLAC PICTURE metadata blocks (Ogg-contained FLAC carries its
-        //  pictures in metadata_block_picture comments instead)
+        // Native FLAC carries its pictures in PICTURE metadata blocks
+        //  (Ogg-contained FLAC uses metadata_block_picture comments instead)
         if (!(flac instanceof FlacOggFile)) {
-            extractNativePictures(path, xhtml, context);
+            pictures.addAll(readNativePictures(path));
         }
+        OggAudioParser.extractPictures(pictures, xhtml, context);
 
         // Extract duration if available from header
         FlacInfo info = flac.getInfo();
@@ -129,8 +131,8 @@ public class FlacParser extends AbstractParser {
     }
 
     /**
-     * Walks the metadata blocks of a native FLAC file and sends any
-     * PICTURE blocks to the embedded document extractor. Their payload is
+     * Walks the metadata blocks of a native FLAC file and returns any
+     * PICTURE blocks. Their payload is
      * identical to the metadata_block_picture comments handled by
      * {@link OggAudioParser}. vorbis-java parses these blocks but keeps
      * them without a public accessor, so they are read through a second
@@ -141,14 +143,13 @@ public class FlacParser extends AbstractParser {
      * FlacFile.getOtherMetadata(), present on their master but unreleased
      * as of 0.8, see https://github.com/Gagravarr/VorbisJava/issues/46
      */
-    private static void extractNativePictures(Path path, XHTMLContentHandler 
xhtml,
-            ParseContext context) throws IOException, SAXException {
-        EmbeddedDocumentExtractor extractor = null;
+    static List<OggAudioParser.PictureBlock> readNativePictures(Path path) 
throws IOException {
+        List<OggAudioParser.PictureBlock> pictures = new ArrayList<>();
         try (InputStream stream = new 
BufferedInputStream(Files.newInputStream(path))) {
             byte[] magic = stream.readNBytes(4);
             if (magic.length != 4 || magic[0] != 'f' || magic[1] != 'L'
                     || magic[2] != 'a' || magic[3] != 'C') {
-                return;
+                return pictures;
             }
             boolean lastBlock = false;
             while (!lastBlock) {
@@ -156,7 +157,7 @@ public class FlacParser extends AbstractParser {
                 //  24 bit BE block length
                 byte[] header = stream.readNBytes(4);
                 if (header.length != 4) {
-                    return;
+                    break;
                 }
                 lastBlock = (header[0] & 0x80) != 0;
                 int blockType = header[0] & 0x7F;
@@ -165,21 +166,22 @@ public class FlacParser extends AbstractParser {
                 if (blockType == PICTURE_BLOCK_TYPE) {
                     byte[] block = stream.readNBytes(blockLength);
                     if (block.length != blockLength) {
-                        return;
+                        break;
                     }
-                    if (extractor == null) {
-                        extractor = 
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+                    OggAudioParser.PictureBlock picture = 
OggAudioParser.PictureBlock.parse(block);
+                    if (picture != null) {
+                        pictures.add(picture);
                     }
-                    OggAudioParser.extractPictureBlock(block, xhtml, context, 
extractor);
                 } else {
                     try {
                         stream.skipNBytes(blockLength);
                     } catch (EOFException e) {
                         //truncated block, stop the walk
-                        return;
+                        break;
                     }
                 }
             }
         }
+        return pictures;
     }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
index e75e7873a4..381469365c 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
@@ -23,6 +23,7 @@ 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.List;
@@ -37,11 +38,7 @@ import org.gagravarr.vorbis.VorbisStyleComments;
 import org.xml.sax.SAXException;
 
 import org.apache.tika.exception.TikaException;
-import org.apache.tika.extractor.EmbeddedDocumentExtractor;
-import org.apache.tika.extractor.EmbeddedDocumentUtil;
-import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Audio;
-import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.KeyPrefix;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Property;
@@ -50,8 +47,8 @@ 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.parser.mp3.ID3Tags;
 import org.apache.tika.sax.XHTMLContentHandler;
 
 /**
@@ -117,8 +114,14 @@ public abstract class OggAudioParser extends 
AbstractParser {
         }
     }
 
-    protected static void extractComments(Metadata metadata, 
XHTMLContentHandler xhtml,
-            VorbisStyleComments comments, ParseContext context)
+    /**
+     * @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 {
         // Get the specific known comments
         metadata.set(TikaCoreProperties.TITLE, comments.getTitle());
@@ -207,22 +210,17 @@ public abstract class OggAudioParser extends 
AbstractParser {
         }
         xhtml.element("p", comments.getGenre());
 
-        // Any embedded pictures, such as cover art, become
-        //  embedded documents of the audio file
-        extractPictures(xhtml, comments, context);
+        // The pictures are the caller's to emit
+        return parsePictures(comments);
     }
 
     /**
-     * Sends the embedded pictures, such as cover art, from the comments to
-     * the embedded document extractor. The pictures are carried as base64
-     * encoded FLAC picture blocks; malformed blocks are skipped silently.
-     * The pictures only become embedded documents, no metadata is recorded
-     * on the audio document itself.
+     * 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 void extractPictures(XHTMLContentHandler xhtml,
-            VorbisStyleComments comments, ParseContext context)
-            throws IOException, SAXException {
-        EmbeddedDocumentExtractor extractor = null;
+    private static List<PictureBlock> parsePictures(VorbisStyleComments 
comments) {
+        List<PictureBlock> pictures = new ArrayList<>();
         for (String block : comments.getComments(METADATA_BLOCK_PICTURE)) {
             byte[] decoded;
             try {
@@ -231,80 +229,80 @@ public abstract class OggAudioParser extends 
AbstractParser {
                 //not valid base64, skip
                 continue;
             }
-            if (extractor == null) {
-                extractor = 
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+            PictureBlock picture = PictureBlock.parse(decoded);
+            if (picture != null) {
+                pictures.add(picture);
             }
-            extractPictureBlock(decoded, xhtml, context, extractor);
         }
+        return pictures;
     }
 
     /**
-     * Parses one FLAC picture block and sends the picture it holds to the
-     * embedded document extractor. Native FLAC PICTURE metadata blocks use
-     * the very same structure, so {@link FlacParser} shares this method.
-     * Malformed or truncated blocks are skipped silently.
+     * 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 extractPictureBlock(byte[] block, XHTMLContentHandler xhtml,
-            ParseContext context, EmbeddedDocumentExtractor extractor)
-            throws IOException, SAXException {
-        // The picture block holds 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
-        int pictureType;
-        String mimeType;
-        String description;
-        byte[] picture;
-        try {
-            ByteBuffer buffer = ByteBuffer.wrap(block);
-            pictureType = buffer.getInt();
-            mimeType = getPrefixedString(buffer, StandardCharsets.ISO_8859_1);
-            if (mimeType == null || "-->".equals(mimeType)) {
-                // Malformed, or a link to a picture rather than an
-                // embedded one
-                return;
-            }
-            description = getPrefixedString(buffer, StandardCharsets.UTF_8);
-            if (description == null) {
-                return;
-            }
-            // Width, height, color depth and number of colors
-            buffer.position(buffer.position() + 16);
-            int dataLength = buffer.getInt();
-            if (dataLength <= 0 || dataLength > buffer.remaining()) {
-                return;
-            }
-            picture = new byte[dataLength];
-            buffer.get(picture);
-        } catch (BufferUnderflowException | IllegalArgumentException e) {
-            //truncated picture block, skip
-            return;
+    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);
+    }
 
-        Metadata pictureMetadata = Metadata.newInstance(context);
-        pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
-                TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
-        if (!mimeType.isEmpty()) {
-            pictureMetadata.set(HttpHeaders.CONTENT_TYPE, mimeType);
-        }
-        if (!description.isEmpty()) {
-            pictureMetadata.set(TikaCoreProperties.TITLE, description);
-        }
-        //the FLAC picture block reuses the ID3v2 APIC picture types
-        if (pictureType >= 0 && pictureType < ID3Tags.PICTURE_TYPES.length) {
-            pictureMetadata.set(TikaCoreProperties.DESCRIPTION,
-                    ID3Tags.PICTURE_TYPES[pictureType]);
+    /**
+     * 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;
         }
-        if (extractor.shouldParseEmbedded(pictureMetadata, context)) {
-            try (TikaInputStream pictureStream = TikaInputStream.get(picture)) 
{
-                extractor.parseEmbedded(pictureStream, xhtml, pictureMetadata, 
context, true);
+
+        /**
+         * 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;
             }
         }
     }
 
-    /**
-     * Reads a 32 bit length prefixed string from the buffer, or null if the
-     * declared length is invalid for the remaining data.
-     */
     private static String getPrefixedString(ByteBuffer buffer, Charset 
charset) {
         int length = buffer.getInt();
         if (length < 0 || length > buffer.remaining()) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OpusParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OpusParser.java
index 3329aa53ac..d9c4a4360a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OpusParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OpusParser.java
@@ -77,7 +77,8 @@ public class OpusParser extends OggAudioParser {
         extractInfo(metadata, opus.getInfo());
 
         // Extract any Vorbis comments
-        extractComments(metadata, xhtml, opus.getTags(), context);
+        extractPictures(extractComments(metadata, xhtml, opus.getTags()),
+                xhtml, context);
 
         // Extract the audio length
         extractDuration(metadata, xhtml, opus, opus);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/SpeexParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/SpeexParser.java
index 4e9b178780..104bf8f3bf 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/SpeexParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/SpeexParser.java
@@ -77,7 +77,8 @@ public class SpeexParser extends OggAudioParser {
         extractInfo(metadata, speex.getInfo());
 
         // Extract any Vorbis comments
-        extractComments(metadata, xhtml, speex.getTags(), context);
+        extractPictures(extractComments(metadata, xhtml, speex.getTags()),
+                xhtml, context);
 
         // Extract the audio length
         extractDuration(metadata, xhtml, speex, speex);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/TheoraParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/TheoraParser.java
index a201c47604..09639e09cf 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/TheoraParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/TheoraParser.java
@@ -78,7 +78,9 @@ public class TheoraParser extends AbstractParser {
         extractInfo(metadata, theora.getInfo());
 
         // Extract the common Theora comments
-        OggAudioParser.extractComments(metadata, xhtml, theora.getComments(), 
context);
+        OggAudioParser.extractPictures(
+                OggAudioParser.extractComments(metadata, xhtml, 
theora.getComments()),
+                xhtml, context);
 
         // Extract any soundtracks
         for (OggAudioHeaders audio : theora.getSoundtracks()) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java
index d38bba037e..d19d29b028 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java
@@ -76,7 +76,8 @@ public class VorbisParser extends OggAudioParser {
         extractInfo(metadata, vorbis.getInfo());
 
         // Extract any Vorbis comments
-        extractComments(metadata, xhtml, vorbis.getComment(), context);
+        extractPictures(extractComments(metadata, xhtml, vorbis.getComment()),
+                xhtml, context);
 
         // Extract the audio length
         extractDuration(metadata, xhtml, vorbis, vorbis);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/audio/CoverArtTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/audio/CoverArtTest.java
new file mode 100644
index 0000000000..3d73d6f251
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/audio/CoverArtTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.audio;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+public class CoverArtTest {
+
+    @Test
+    public void testFrontCoverWins() {
+        //back cover, front cover: the front cover, wherever it is
+        assertEquals(1, CoverArt.thumbnailIndex(Arrays.asList(4, 3)));
+        assertEquals(0, CoverArt.thumbnailIndex(Arrays.asList(3, 0)));
+    }
+
+    @Test
+    public void testOtherBeatsClassifiedNonCovers() {
+        //back cover, leaflet, "Other": the unclassified one is the main art
+        assertEquals(2, CoverArt.thumbnailIndex(Arrays.asList(4, 5, 0)));
+        //unknown type counts like "Other"
+        assertEquals(1, CoverArt.thumbnailIndex(Arrays.asList(4, -1)));
+    }
+
+    @Test
+    public void testTypesBeyondTheId3TableCountAsUnknown() {
+        assertEquals(-1, new CoverArt.Picture(200, null, null, new 
byte[0]).normalizedType());
+        assertEquals(CoverArt.FRONT_COVER,
+                new CoverArt.Picture(CoverArt.FRONT_COVER, null, null, new 
byte[0])
+                        .normalizedType());
+        //a back cover first, then an out-of-table type: the unknown one wins
+        assertEquals(1, CoverArt.thumbnailIndex(Arrays.asList(4,
+                new CoverArt.Picture(200, null, null, new 
byte[0]).normalizedType())));
+    }
+
+    @Test
+    public void testFirstPictureAsLastResort() {
+        assertEquals(0, CoverArt.thumbnailIndex(Arrays.asList(4, 5)));
+        assertEquals(-1, CoverArt.thumbnailIndex(Collections.emptyList()));
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java
index b48dee0f56..59808166c1 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java
@@ -145,7 +145,8 @@ public class Mp3ParserTest extends TikaTest {
 
         Metadata pictureMetadata = metadataList.get(1);
         assertEquals("image/png", 
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+        //the front cover is the file's thumbnail
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
                 
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
         assertEquals("Test Cover", 
pictureMetadata.get(TikaCoreProperties.TITLE));
         assertEquals("Cover (front)", 
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
@@ -182,7 +183,7 @@ public class Mp3ParserTest extends TikaTest {
         //the 30x30 back cover second
         Metadata front = metadataList.get(1);
         assertEquals("image/png", front.get(HttpHeaders.CONTENT_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
                 front.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
         assertEquals("Front Cover", front.get(TikaCoreProperties.TITLE));
         assertEquals("Cover (front)", 
front.get(TikaCoreProperties.DESCRIPTION));
@@ -521,6 +522,73 @@ public class Mp3ParserTest extends TikaTest {
     }
 
     // wraps the audio of testMP3noid3.mp3 in an ID3v2.3 tag holding the 
single given frame
+    /**
+     * The thumbnail follows the picture types, not the file order: a back
+     * cover that comes first stays INLINE, the front cover behind it is
+     * the THUMBNAIL.
+     */
+    @Test
+    public void testBackCoverBeforeFrontCover() throws Exception {
+        byte[] mp3 = mp3WithFrames(
+                id3Frame("APIC", apicBody(4, "the back")),
+                id3Frame("APIC", apicBody(3, "the front")));
+        try (TikaInputStream tis = TikaInputStream.get(mp3)) {
+            List<Metadata> metadataList =
+                    getRecursiveMetadata(tis, new Metadata(), new 
ParseContext(), false);
+            assertEquals(3, metadataList.size());
+            assertEquals("Cover (back)", 
metadataList.get(1).get(TikaCoreProperties.DESCRIPTION));
+            assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
+                    
metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+            assertEquals("Cover (front)", 
metadataList.get(2).get(TikaCoreProperties.DESCRIPTION));
+            
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name(),
+                    
metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        }
+    }
+
+    private static byte[] apicBody(int pictureType, String description) throws 
Exception {
+        ByteArrayOutputStream body = new ByteArrayOutputStream();
+        body.write(0); //ISO-8859-1
+        body.write("image/png".getBytes(StandardCharsets.ISO_8859_1));
+        body.write(0);
+        body.write(pictureType);
+        body.write(description.getBytes(StandardCharsets.ISO_8859_1));
+        body.write(0);
+        body.write(new byte[]{(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, 
'\n'});
+        return body.toByteArray();
+    }
+
+    private static byte[] id3Frame(String frameId, byte[] body) throws 
Exception {
+        ByteArrayOutputStream frame = new ByteArrayOutputStream();
+        frame.write(frameId.getBytes(StandardCharsets.US_ASCII));
+        //ID3v2.3 frame sizes are plain 32 bit big endian
+        frame.write(new byte[]{(byte) (body.length >>> 24), (byte) 
(body.length >>> 16),
+                (byte) (body.length >>> 8), (byte) body.length});
+        frame.write(new byte[]{0, 0});
+        frame.write(body);
+        return frame.toByteArray();
+    }
+
+    private byte[] mp3WithFrames(byte[]... frames) throws Exception {
+        byte[] audio;
+        try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testMP3noid3.mp3")) {
+            audio = tis.readAllBytes();
+        }
+        ByteArrayOutputStream body = new ByteArrayOutputStream();
+        for (byte[] frame : frames) {
+            body.write(frame);
+        }
+        ByteArrayOutputStream mp3 = new ByteArrayOutputStream();
+        mp3.write("ID3".getBytes(StandardCharsets.US_ASCII));
+        mp3.write(new byte[]{3, 0, 0});
+        //the tag size is synchsafe (7 bits per byte)
+        int size = body.size();
+        mp3.write(new byte[]{(byte) ((size >>> 21) & 0x7f), (byte) ((size >>> 
14) & 0x7f),
+                (byte) ((size >>> 7) & 0x7f), (byte) (size & 0x7f)});
+        mp3.write(body.toByteArray());
+        mp3.write(audio);
+        return mp3.toByteArray();
+    }
+
     private byte[] mp3WithFrame(String frameId, byte[] body) throws Exception {
         byte[] audio;
         try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testMP3noid3.mp3")) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
index ef8d1f9c33..c1b663ff2d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
@@ -165,7 +166,8 @@ public class MP4ParserTest extends TikaTest {
 
         Metadata pictureMetadata = metadataList.get(1);
         assertEquals("image/png", 
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+        //the cover is the file's thumbnail
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
                 
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
     }
 
@@ -179,9 +181,10 @@ public class MP4ParserTest extends TikaTest {
 
         assertEquals(3, metadataList.size());
         //a png data atom (well-known type 14) followed by a jpeg one (13)
+        //covr carries no picture type, so the first image is the thumbnail
         Metadata front = metadataList.get(1);
         assertEquals("image/png", front.get(HttpHeaders.CONTENT_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
                 front.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
         Metadata back = metadataList.get(2);
         assertEquals("image/jpeg", back.get(HttpHeaders.CONTENT_TYPE));
@@ -189,6 +192,45 @@ public class MP4ParserTest extends TikaTest {
                 back.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
     }
 
+    /**
+     * A cover in a second udta box is still an inline picture: the first
+     * cover of the file is its thumbnail, not the first of every box
+     */
+    @Test
+    public void testCoversAcrossUserDataBoxes() throws Exception {
+        byte[] file;
+        try (InputStream is = 
getResourceAsStream("/test-documents/testMP4_coverArt.m4a")) {
+            file = is.readAllBytes();
+        }
+        //append a copy of the file's udta box (with its covr) at the top level
+        int udtaName = indexOf(file, 
"udta".getBytes(StandardCharsets.ISO_8859_1));
+        assertTrue(udtaName >= 4, "fixture must contain a udta box");
+        int udta = udtaName - 4;
+        int size = ByteBuffer.wrap(file, udta, 4).getInt();
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        bos.write(file);
+        bos.write(file, udta, size);
+
+        List<Metadata> metadataList;
+        try (TikaInputStream tis = TikaInputStream.get(bos.toByteArray())) {
+            metadataList = getRecursiveMetadata(tis, new Metadata(), new 
ParseContext(), false);
+        }
+        assertEquals(3, metadataList.size());
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                
metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+                
metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    private static int indexOf(byte[] haystack, byte[] needle) {
+        for (int i = 0; i <= haystack.length - needle.length; i++) {
+            if (Arrays.equals(Arrays.copyOfRange(haystack, i, i + 
needle.length), needle)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
     // TODO Test an old QuickTime Video File
     @Test
     public void testVideoFrameRate() throws Exception {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/FlacParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/FlacParserTest.java
index e9dc263ec6..4fde9e14ee 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/FlacParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/FlacParserTest.java
@@ -18,9 +18,15 @@ package org.apache.tika.parser.ogg;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.List;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
 import org.apache.tika.TikaTest;
 import org.apache.tika.metadata.HttpHeaders;
@@ -45,7 +51,7 @@ public class FlacParserTest extends TikaTest {
 
         Metadata pictureMetadata = metadataList.get(1);
         assertEquals("image/png", 
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
                 
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
         assertEquals("Test Cover", 
pictureMetadata.get(TikaCoreProperties.TITLE));
         assertEquals("Cover (front)", 
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
@@ -65,10 +71,87 @@ public class FlacParserTest extends TikaTest {
         assertEquals("image/png", front.get(HttpHeaders.CONTENT_TYPE));
         assertEquals("Front Cover", front.get(TikaCoreProperties.TITLE));
         assertEquals("Cover (front)", 
front.get(TikaCoreProperties.DESCRIPTION));
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                front.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
 
         Metadata back = metadataList.get(2);
         assertEquals("image/png", back.get(HttpHeaders.CONTENT_TYPE));
         assertEquals("Back Cover", back.get(TikaCoreProperties.TITLE));
         assertEquals("Cover (back)", back.get(TikaCoreProperties.DESCRIPTION));
+        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+                back.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    /**
+     * A file carrying a picture in a metadata_block_picture comment and
+     * another in a native PICTURE block still has exactly one thumbnail:
+     * both sources are merged before the pick (both are front covers here,
+     * so the first one, from the comment, wins).
+     */
+    @Test
+    public void testCommentAndNativePictureYieldOneThumbnail() throws 
Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testFLAC_commentAndNativePicture.flac");
+
+        assertEquals(3, metadataList.size());
+        int thumbnails = 0;
+        for (Metadata m : metadataList) {
+            if (TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name()
+                    .equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE))) 
{
+                thumbnails++;
+            }
+        }
+        assertEquals(1, thumbnails);
+        assertEquals("Comment cover", 
metadataList.get(1).get(TikaCoreProperties.TITLE));
+        assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name(),
+                
metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
+                
metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    /**
+     * A PICTURE block that declares more data than the file has left ends
+     * the walk, but the pictures before it survive: the walk breaks instead
+     * of returning or throwing (regression guard for the return-vs-break in
+     * readNativePictures).
+     */
+    @Test
+    public void testTruncatedPictureBlockKeepsEarlierPictures(@TempDir Path 
tmp) throws Exception {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        out.write("fLaC".getBytes(StandardCharsets.US_ASCII));
+        byte[] picture = pictureBlock(3, "image/png", "front");
+        out.write(blockHeader(false, 6, picture.length));
+        out.write(picture);
+        //a second PICTURE block declaring far more data than follows
+        out.write(blockHeader(true, 6, 0x00FFFF));
+        out.write(new byte[]{1, 2, 3});
+        Path flac = tmp.resolve("truncated.flac");
+        Files.write(flac, out.toByteArray());
+
+        List<OggAudioParser.PictureBlock> pictures = 
FlacParser.readNativePictures(flac);
+        assertEquals(1, pictures.size());
+    }
+
+    private static byte[] blockHeader(boolean last, int type, int length) {
+        return new byte[]{(byte) ((last ? 0x80 : 0) | type),
+                (byte) (length >>> 16), (byte) (length >>> 8), (byte) length};
+    }
+
+    private static byte[] pictureBlock(int pictureType, String mimeType, 
String description)
+            throws Exception {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        DataOutputStream data = new DataOutputStream(out);
+        data.writeInt(pictureType);
+        byte[] mime = mimeType.getBytes(StandardCharsets.ISO_8859_1);
+        data.writeInt(mime.length);
+        data.write(mime);
+        byte[] desc = description.getBytes(StandardCharsets.UTF_8);
+        data.writeInt(desc.length);
+        data.write(desc);
+        data.write(new byte[16]); //geometry
+        byte[] image = {(byte) 0x89, 'P', 'N', 'G'};
+        data.writeInt(image.length);
+        data.write(image);
+        return out.toByteArray();
     }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserTest.java
index d40a8f52ae..610d09e164 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserTest.java
@@ -113,19 +113,66 @@ public class OggAudioParserTest {
      */
     @Test
     public void testMetadataBlockPictureBecomesEmbeddedDocument() throws 
Exception {
+        String block = pictureBlock(4, "Back cover");//picture type: cover 
(back)
+
+        List<Metadata> pictures = new ArrayList<>();
+        ParseContext context = collectingContext(pictures);
+
+        Metadata metadata = extractComments(context, "metadata_block_picture", 
block);
+
+        assertEquals(1, pictures.size());
+        Metadata pictureMetadata = pictures.get(0);
+        assertEquals("image/jpeg", 
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
+        //the only picture is the file's thumbnail, front cover or not
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("Back cover", 
pictureMetadata.get(TikaCoreProperties.TITLE));
+        assertEquals("Cover (back)", 
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
+        assertNull(metadata.get("vorbis:metadata_block_picture"));
+    }
+
+    /**
+     * The front cover is the thumbnail even when another picture comes
+     * first; the others are inline pictures.
+     */
+    @Test
+    public void testFrontCoverIsTheThumbnail() throws Exception {
+        List<Metadata> pictures = new ArrayList<>();
+        ParseContext context = collectingContext(pictures);
+
+        extractComments(context, "metadata_block_picture", pictureBlock(4, 
"Back cover"),
+                "metadata_block_picture", pictureBlock(3, "Front cover"),
+                "metadata_block_picture", pictureBlock(8, "Artist"));
+
+        assertEquals(3, pictures.size());
+        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+                
pictures.get(0).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("Front cover", 
pictures.get(1).get(TikaCoreProperties.TITLE));
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                
pictures.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+                
pictures.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    /**
+     * Builds a base64 metadata_block_picture comment holding a jpeg of the
+     * given picture type and description.
+     */
+    private static String pictureBlock(int pictureType, String 
descriptionText) {
         byte[] pictureData = new byte[]{1, 2, 3, 4};
         byte[] mime = "image/jpeg".getBytes(StandardCharsets.ISO_8859_1);
-        byte[] description = "Back cover".getBytes(StandardCharsets.UTF_8);
+        byte[] description = descriptionText.getBytes(StandardCharsets.UTF_8);
         ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + mime.length + 4 + 
description.length +
                 16 + 4 + pictureData.length);
-        buffer.putInt(4);//picture type: cover (back)
+        buffer.putInt(pictureType);
         buffer.putInt(mime.length).put(mime);
         buffer.putInt(description.length).put(description);
         buffer.putInt(1).putInt(1).putInt(24).putInt(0);//width, height, 
depth, colors
         buffer.putInt(pictureData.length).put(pictureData);
-        String block = Base64.getEncoder().encodeToString(buffer.array());
+        return Base64.getEncoder().encodeToString(buffer.array());
+    }
 
-        List<Metadata> pictures = new ArrayList<>();
+    private static ParseContext collectingContext(List<Metadata> pictures) {
         ParseContext context = new ParseContext();
         context.set(EmbeddedDocumentExtractor.class, new 
EmbeddedDocumentExtractor() {
             @Override
@@ -139,17 +186,7 @@ public class OggAudioParserTest {
                 pictures.add(metadata);
             }
         });
-
-        Metadata metadata = extractComments(context, "metadata_block_picture", 
block);
-
-        assertEquals(1, pictures.size());
-        Metadata pictureMetadata = pictures.get(0);
-        assertEquals("image/jpeg", 
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
-                
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
-        assertEquals("Back cover", 
pictureMetadata.get(TikaCoreProperties.TITLE));
-        assertEquals("Cover (back)", 
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
-        assertNull(metadata.get("vorbis:metadata_block_picture"));
+        return context;
     }
 
     private static Metadata extractComments(String... keysAndValues) throws 
Exception {
@@ -173,7 +210,10 @@ public class OggAudioParserTest {
         Metadata metadata = new Metadata();
         XHTMLContentHandler xhtml = new XHTMLContentHandler(new 
DefaultHandler(), metadata);
         xhtml.startDocument();
-        OggAudioParser.extractComments(metadata, xhtml, comments, context);
+        //emit the returned pictures, as the parsers do
+        OggAudioParser.extractPictures(
+                OggAudioParser.extractComments(metadata, xhtml, comments),
+                xhtml, context);
         xhtml.endDocument();
         return metadata;
     }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java
index 968e5aee8d..39e071ec65 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java
@@ -70,7 +70,7 @@ public class VorbisParserTest extends TikaTest {
 
         Metadata pictureMetadata = metadataList.get(1);
         assertEquals("image/png", 
pictureMetadata.get(HttpHeaders.CONTENT_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
                 
pictureMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
         assertEquals("Test Cover", 
pictureMetadata.get(TikaCoreProperties.TITLE));
         assertEquals("Cover (front)", 
pictureMetadata.get(TikaCoreProperties.DESCRIPTION));
@@ -91,11 +91,15 @@ public class VorbisParserTest extends TikaTest {
         assertEquals("image/png", front.get(HttpHeaders.CONTENT_TYPE));
         assertEquals("Front Cover", front.get(TikaCoreProperties.TITLE));
         assertEquals("Cover (front)", 
front.get(TikaCoreProperties.DESCRIPTION));
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                front.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
 
         Metadata back = metadataList.get(2);
         assertEquals("image/png", back.get(HttpHeaders.CONTENT_TYPE));
         assertEquals("Back Cover", back.get(TikaCoreProperties.TITLE));
         assertEquals("Cover (back)", back.get(TikaCoreProperties.DESCRIPTION));
+        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
+                back.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
     }
 
     private static Metadata extractInfo(int upper, int nominal, int lower) 
throws Exception {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testFLAC_commentAndNativePicture.flac
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testFLAC_commentAndNativePicture.flac
new file mode 100644
index 0000000000..2ca6825696
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testFLAC_commentAndNativePicture.flac
 differ

Reply via email to