Skip to content

Commit cca1477

Browse files
authored
TIKA-4850: Emit audio cover art as a THUMBNAIL embedded document (#3090)
1 parent f1e4c23 commit cca1477

18 files changed

Lines changed: 625 additions & 159 deletions

File tree

CHANGES.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
Release 4.1.0 - unreleased
22

3+
* Audio cover art is emitted as a THUMBNAIL embedded document, like the
4+
preview image of the document container formats: the front cover (ID3
5+
APIC and FLAC/Vorbis picture type 3), else the first picture of type
6+
"Other" or unknown, else the first picture, and the first covr image
7+
of an MP4. Further pictures
8+
stay INLINE. Clients that looked for cover art as INLINE need to
9+
accept THUMBNAIL as well (TIKA-4850).
10+
311
* tika-grpc resolves its plugin-roots fallback against the install
412
layout via DefaultPluginsDir instead of a working-directory-relative
513
pf4j default, and a WARN names the resolved directory when no plugins
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.audio;
18+
19+
import java.io.IOException;
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
23+
import org.xml.sax.SAXException;
24+
25+
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
26+
import org.apache.tika.extractor.EmbeddedDocumentUtil;
27+
import org.apache.tika.io.TikaInputStream;
28+
import org.apache.tika.metadata.HttpHeaders;
29+
import org.apache.tika.metadata.Metadata;
30+
import org.apache.tika.metadata.TikaCoreProperties;
31+
import org.apache.tika.parser.ParseContext;
32+
import org.apache.tika.parser.mp3.ID3Tags;
33+
import org.apache.tika.sax.XHTMLContentHandler;
34+
35+
/**
36+
* Picks the picture that stands for an audio file among its embedded
37+
* pictures and sends them all to the embedded document extractor. That
38+
* picture is emitted as a
39+
* {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}, like the
40+
* preview image of the document container formats, so a client can find
41+
* the representative image of any file the same way; the other pictures
42+
* are {@link TikaCoreProperties.EmbeddedResourceType#INLINE}. See TIKA-4850.
43+
*/
44+
public final class CoverArt {
45+
46+
/**
47+
* The ID3v2 APIC picture type of the front cover, shared by the FLAC
48+
* and Vorbis picture blocks.
49+
*/
50+
public static final int FRONT_COVER = 3;
51+
52+
/**
53+
* The ID3v2 APIC picture type "Other". Many taggers store the main
54+
* cover art with this type instead of marking it a front cover.
55+
*/
56+
public static final int OTHER = 0;
57+
58+
private CoverArt() {
59+
}
60+
61+
/**
62+
* One embedded picture of an audio file. The type is the ID3v2 APIC
63+
* picture type, shared by the FLAC and Vorbis picture blocks; mime type
64+
* and description may be null or empty.
65+
*/
66+
public record Picture(int type, String mimeType, String description, byte[] data) {
67+
68+
/**
69+
* The type as {@link #thumbnailIndex(List)} sees it: a value beyond
70+
* the ID3 picture type table counts as unknown.
71+
*/
72+
int normalizedType() {
73+
return type >= ID3Tags.PICTURE_TYPES.length ? -1 : type;
74+
}
75+
}
76+
77+
/**
78+
* Returns the index of the picture to mark as the thumbnail: the first
79+
* front cover; else the first picture whose type is "Other" or unknown,
80+
* which is where taggers put the main art when they do not classify it,
81+
* rather than e.g. a back cover or a leaflet that happens to come first;
82+
* else the first picture.
83+
*
84+
* @param pictureTypes the picture types in file order; a negative value
85+
* for a picture whose type is unknown
86+
* @return the index, or -1 if there are no pictures
87+
*/
88+
public static int thumbnailIndex(List<Integer> pictureTypes) {
89+
if (pictureTypes.isEmpty()) {
90+
return -1;
91+
}
92+
int front = pictureTypes.indexOf(FRONT_COVER);
93+
if (front >= 0) {
94+
return front;
95+
}
96+
for (int i = 0; i < pictureTypes.size(); i++) {
97+
if (pictureTypes.get(i) <= OTHER) {
98+
return i;
99+
}
100+
}
101+
return 0;
102+
}
103+
104+
/**
105+
* The resource type of the picture at the given index.
106+
*/
107+
public static TikaCoreProperties.EmbeddedResourceType resourceType(int index,
108+
int thumbnailIndex) {
109+
return index == thumbnailIndex ? TikaCoreProperties.EmbeddedResourceType.THUMBNAIL
110+
: TikaCoreProperties.EmbeddedResourceType.INLINE;
111+
}
112+
113+
/**
114+
* Sends the pictures of one audio file to the embedded document
115+
* extractor: the one {@link #thumbnailIndex(List)} picks as the
116+
* THUMBNAIL, the others as INLINE pictures. The pictures only become
117+
* embedded documents, no metadata is recorded on the audio document
118+
* itself. Call once per file, with all of its pictures, so exactly one
119+
* of them is the thumbnail.
120+
*/
121+
public static void extractPictures(List<Picture> pictures, XHTMLContentHandler xhtml,
122+
ParseContext context) throws IOException, SAXException {
123+
if (pictures.isEmpty()) {
124+
return;
125+
}
126+
List<Integer> pictureTypes = new ArrayList<>();
127+
for (Picture picture : pictures) {
128+
pictureTypes.add(picture.normalizedType());
129+
}
130+
int thumbnailIndex = thumbnailIndex(pictureTypes);
131+
EmbeddedDocumentExtractor extractor =
132+
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
133+
for (int i = 0; i < pictures.size(); i++) {
134+
Picture picture = pictures.get(i);
135+
Metadata pictureMetadata = Metadata.newInstance(context);
136+
pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
137+
resourceType(i, thumbnailIndex).name());
138+
if (picture.mimeType() != null && !picture.mimeType().isEmpty()) {
139+
pictureMetadata.set(HttpHeaders.CONTENT_TYPE, picture.mimeType());
140+
}
141+
if (picture.description() != null && !picture.description().isEmpty()) {
142+
pictureMetadata.set(TikaCoreProperties.TITLE, picture.description());
143+
}
144+
if (picture.type() >= 0 && picture.type() < ID3Tags.PICTURE_TYPES.length) {
145+
pictureMetadata.set(TikaCoreProperties.DESCRIPTION,
146+
ID3Tags.PICTURE_TYPES[picture.type()]);
147+
}
148+
if (extractor.shouldParseEmbedded(pictureMetadata, context)) {
149+
try (TikaInputStream pictureStream = TikaInputStream.get(picture.data())) {
150+
extractor.parseEmbedded(pictureStream, xhtml, pictureMetadata, context, true);
151+
}
152+
}
153+
}
154+
}
155+
}

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828

2929
import org.apache.tika.annotation.TikaComponent;
3030
import org.apache.tika.exception.TikaException;
31-
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
32-
import org.apache.tika.extractor.EmbeddedDocumentUtil;
3331
import org.apache.tika.io.TailStream;
3432
import org.apache.tika.io.TikaInputStream;
3533
import org.apache.tika.metadata.Audio;
@@ -41,6 +39,7 @@
4139
import org.apache.tika.mime.MediaType;
4240
import org.apache.tika.parser.ParseContext;
4341
import org.apache.tika.parser.Parser;
42+
import org.apache.tika.parser.audio.CoverArt;
4443
import org.apache.tika.parser.audio.NumberAndTotal;
4544
import org.apache.tika.parser.mp3.ID3Tags.ID3Comment;
4645
import org.apache.tika.parser.mp3.ID3Tags.ID3Picture;
@@ -308,40 +307,21 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata
308307

309308
/**
310309
* Sends the embedded pictures, such as cover art, from the ID3v2 tags
311-
* to the embedded document extractor. The pictures only become embedded
312-
* documents, no metadata is recorded on the audio document itself.
310+
* to the embedded document extractor;
311+
* {@link CoverArt#thumbnailIndex(java.util.List)} decides which of them
312+
* is the file's thumbnail.
313313
*/
314314
private static void extractPictures(ID3Tags[] tags, XHTMLContentHandler xhtml,
315315
ParseContext context)
316316
throws IOException, SAXException {
317-
EmbeddedDocumentExtractor extractor = null;
317+
List<CoverArt.Picture> pictures = new ArrayList<>();
318318
for (ID3Tags tag : tags) {
319319
for (ID3Picture picture : tag.getPictures()) {
320-
if (extractor == null) {
321-
extractor = EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
322-
}
323-
Metadata pictureMetadata = Metadata.newInstance(context);
324-
pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
325-
TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
326-
if (picture.getMimeType() != null) {
327-
pictureMetadata.set(HttpHeaders.CONTENT_TYPE, picture.getMimeType());
328-
}
329-
if (picture.getDescription() != null && !picture.getDescription().isEmpty()) {
330-
pictureMetadata.set(TikaCoreProperties.TITLE, picture.getDescription());
331-
}
332-
if (picture.getPictureType() >= 0 &&
333-
picture.getPictureType() < ID3Tags.PICTURE_TYPES.length) {
334-
pictureMetadata.set(TikaCoreProperties.DESCRIPTION,
335-
ID3Tags.PICTURE_TYPES[picture.getPictureType()]);
336-
}
337-
if (extractor.shouldParseEmbedded(pictureMetadata, context)) {
338-
try (TikaInputStream pictureStream = TikaInputStream.get(picture.getData())) {
339-
extractor.parseEmbedded(pictureStream, xhtml, pictureMetadata, context,
340-
true);
341-
}
342-
}
320+
pictures.add(new CoverArt.Picture(picture.getPictureType(),
321+
picture.getMimeType(), picture.getDescription(), picture.getData()));
343322
}
344323
}
324+
CoverArt.extractPictures(pictures, xhtml, context);
345325
}
346326

347327
/**

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.ArrayList;
2424
import java.util.Arrays;
2525
import java.util.List;
26+
import java.util.concurrent.atomic.AtomicInteger;
2627

2728
import com.drew.imaging.mp4.Mp4Handler;
2829
import com.drew.lang.annotations.NotNull;
@@ -68,6 +69,8 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
6869
//duration of the current track's leading empty edit(s) ('elst' entries
6970
//with media time -1), in movie timescale units; -1 if the track has none
7071
private long emptyEditDuration = -1;
72+
//cover images emitted so far, across all udta boxes of the file
73+
private final AtomicInteger coverCount = new AtomicInteger();
7174

7275
public TikaMp4BoxHandler(Metadata metadata, org.apache.tika.metadata.Metadata tikaMetadata,
7376
XHTMLContentHandler xhtml, ParseContext parseContext) {
@@ -148,7 +151,7 @@ private Mp4Handler<?> processUserData(String box, byte[] payload, Mp4Context con
148151
return this;
149152
}
150153
try {
151-
new TikaUserDataBox(box, payload, tikaMetadata, xhtml, parseContext)
154+
new TikaUserDataBox(box, payload, tikaMetadata, xhtml, parseContext, coverCount)
152155
.addMetadata(directory);
153156
} catch (SAXException e) {
154157
throw new IOException(e);

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.io.IOException;
2020
import java.nio.charset.StandardCharsets;
21+
import java.util.concurrent.atomic.AtomicInteger;
2122

2223
import com.drew.lang.SequentialByteArrayReader;
2324
import com.drew.lang.SequentialReader;
@@ -52,15 +53,30 @@ public class TikaUserDataBox {
5253
private String coordinateString;
5354

5455
private boolean isQuickTime = false;
56+
//covr carries no picture type, so the first cover of the file is the
57+
//thumbnail; the count is shared across the file's udta boxes
58+
private final AtomicInteger coverCount;
5559
private final Metadata metadata;
5660
private final XHTMLContentHandler xhtml;
5761
private final ParseContext parseContext;
5862
public TikaUserDataBox(@NotNull String box, byte[] payload, Metadata metadata,
5963
XHTMLContentHandler xhtml, ParseContext parseContext)
6064
throws IOException, SAXException {
65+
this(box, payload, metadata, xhtml, parseContext, new AtomicInteger());
66+
}
67+
68+
/**
69+
* @param coverCount the number of cover images already emitted for the
70+
* file, shared across its udta boxes
71+
*/
72+
public TikaUserDataBox(@NotNull String box, byte[] payload, Metadata metadata,
73+
XHTMLContentHandler xhtml, ParseContext parseContext,
74+
AtomicInteger coverCount)
75+
throws IOException, SAXException {
6176
this.metadata = metadata;
6277
this.xhtml = xhtml;
6378
this.parseContext = parseContext;
79+
this.coverCount = coverCount;
6480
int length = payload.length;
6581
SequentialReader reader = new SequentialByteArrayReader(payload);
6682
while (reader.getPosition() < (long) length) {
@@ -228,16 +244,19 @@ private void processIList(SequentialReader reader, long totalLen)
228244

229245

230246
/**
231-
* Sends one embedded cover image to the embedded document extractor.
232-
* The image only becomes an embedded document, no metadata is recorded
233-
* on the audio document itself.
247+
* Sends one embedded cover image to the embedded document extractor:
248+
* the first as the file's thumbnail, any further one as an inline
249+
* picture. The image only becomes an embedded document, no metadata is
250+
* recorded on the audio document itself.
234251
*/
235252
private void handleCoverArt(SequentialReader reader, long valueType, int length)
236253
throws IOException {
237254
byte[] picture = reader.getBytes(length);
238255
Metadata pictureMetadata = Metadata.newInstance(parseContext);
239256
pictureMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
240-
TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
257+
(coverCount.getAndIncrement() == 0
258+
? TikaCoreProperties.EmbeddedResourceType.THUMBNAIL
259+
: TikaCoreProperties.EmbeddedResourceType.INLINE).name());
241260
//the data atom's well-known value type declares the image format;
242261
//for any other type leave the content type for auto-detection
243262
if (valueType == 13) {

0 commit comments

Comments
 (0)