Skip to content

Commit 93ecf6c

Browse files
dschmidtTHausherr
authored andcommitted
TIKA-2861: Extract QuickTime GPS location and item-list metadata in MP4Parser (#2935)
* [TIKA-2861] Expose QuickTime item-list metadata in MP4Parser The MP4 handler descends into the moov/meta container but skips the keys and ilst boxes, so the QuickTime metadata stored there (the com.apple.quicktime.* keys, e.g. the Apple Live Photo content identifier and the ISO 6709 location) was dropped for QuickTime .mov files and any .mp4 carrying it. Parse the keys/ilst boxes in TikaMp4BoxHandler and expose the UTF-8 text values under their key names. This is additive: the udta path and all existing fields are untouched. * [TIKA-2861] Map QuickTime ISO 6709 location to geo:lat/long/alt In addition to the raw com.apple.quicktime.location.ISO6709 value, parse it into the standard geo:lat, geo:long and geo:alt properties. This matches the geo:* output the udta ("(c)xyz") path already produces for other files, and additionally exposes the altitude, which the udta path drops. * [TIKA-2861] Expose the udta ISO 6709 altitude as geo:alt The udta "(c)xyz" box carries an ISO 6709 string whose optional third component is the altitude (ISO 6709 Annex H). Older iOS versions and Google Photos write it with altitude, while Android writes lat/long only. TikaUserDataBox only matched lat/long, so the altitude was dropped. Capture the optional altitude and set geo:alt directly on the Tika metadata: the drewnoakes Mp4Directory only defines latitude/longitude tags, so it cannot carry the altitude to MP4Parser. Lat/long continue to flow through the directory as before. The new crafted fixture is the first test coverage for the udta location path (no existing fixture contained a "(c)xyz" box). * [TIKA-2861] Handle numeric QuickTime metadata value types The item-list parser only emitted UTF-8 text values (well-known type 1) and silently dropped the numeric types. Real iPhone Live Photo videos carry several of those, e.g. com.apple.quicktime.live-photo.vitality-score (float32), live-photo.auto (uint8) and camera.focal_length.35mm_equivalent (int32). Decode the QTFF well-known types 21 (signed int BE), 22 (unsigned int BE), 23 (float32) and 24 (float64) as well. Integers may be 1 to 8 bytes wide. Other value types (images, binary plists) are still skipped.
1 parent 1b5bb37 commit 93ecf6c

5 files changed

Lines changed: 197 additions & 2 deletions

File tree

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: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
package org.apache.tika.parser.mp4;
1818

1919
import java.io.IOException;
20+
import java.math.BigInteger;
21+
import java.nio.ByteBuffer;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.ArrayList;
24+
import java.util.Arrays;
25+
import java.util.List;
26+
import java.util.regex.Matcher;
27+
import java.util.regex.Pattern;
2028

2129
import com.drew.imaging.mp4.Mp4Handler;
2230
import com.drew.lang.annotations.NotNull;
@@ -26,13 +34,31 @@
2634
import com.drew.metadata.mp4.Mp4Context;
2735
import org.xml.sax.SAXException;
2836

37+
import org.apache.tika.metadata.TikaCoreProperties;
2938
import org.apache.tika.parser.mp4.boxes.TikaUserDataBox;
3039
import org.apache.tika.sax.XHTMLContentHandler;
3140

3241
public class TikaMp4BoxHandler extends Mp4BoxHandler {
3342

43+
//QTFF "well-known" metadata item value types
44+
private static final int QT_TEXT_TYPE = 1;
45+
private static final int QT_INT_BE_TYPE = 21;
46+
private static final int QT_UINT_BE_TYPE = 22;
47+
private static final int QT_FLOAT32_TYPE = 23;
48+
private static final int QT_FLOAT64_TYPE = 24;
49+
50+
//QuickTime stores location as an ISO 6709 string (e.g. +32.4720-084.9952+073.827/)
51+
private static final String QT_LOCATION_ISO6709 = "com.apple.quicktime.location.ISO6709";
52+
private static final Pattern ISO6709_PATTERN =
53+
Pattern.compile("([+-]\\d+(?:\\.\\d+)?)([+-]\\d+(?:\\.\\d+)?)([+-]\\d+(?:\\.\\d+)?)?");
54+
3455
org.apache.tika.metadata.Metadata tikaMetadata;
3556
final XHTMLContentHandler xhtml;
57+
58+
//key names for the current 'meta' box, filled from its 'keys' box and consumed
59+
//by the following 'ilst' box (e.g. com.apple.quicktime.content.identifier)
60+
private final List<String> quickTimeMetadataKeys = new ArrayList<>();
61+
3662
public TikaMp4BoxHandler(Metadata metadata, org.apache.tika.metadata.Metadata tikaMetadata,
3763
XHTMLContentHandler xhtml) {
3864
super(metadata);
@@ -42,7 +68,7 @@ public TikaMp4BoxHandler(Metadata metadata, org.apache.tika.metadata.Metadata ti
4268

4369
@Override
4470
public boolean shouldAcceptBox(@NotNull String box) {
45-
if (box.equals("udta")) {
71+
if (box.equals("udta") || box.equals("keys") || box.equals("ilst")) {
4672
return true;
4773
}
4874
return super.shouldAcceptBox(box);
@@ -59,6 +85,12 @@ public Mp4Handler<?> processBox(@NotNull String box, @Nullable byte[] payload,
5985
throws IOException {
6086
if (box.equals("udta")) {
6187
return processUserData(box, payload, context);
88+
} else if (box.equals("keys")) {
89+
processQuickTimeKeys(payload);
90+
return this;
91+
} else if (box.equals("ilst")) {
92+
processQuickTimeItemList(payload);
93+
return this;
6294
}
6395

6496
return super.processBox(box, payload, size, context);
@@ -76,4 +108,124 @@ private Mp4Handler<?> processUserData(String box, byte[] payload, Mp4Context con
76108
}
77109
return this;
78110
}
111+
112+
/**
113+
* Parses the QuickTime metadata 'keys' box, which maps 1-based indices to key
114+
* names such as {@code com.apple.quicktime.content.identifier}. The base MP4
115+
* handler descends into the enclosing 'meta' container but skips 'keys'/'ilst',
116+
* so this metadata (content identifier, ISO 6709 location, make/model, ...) was
117+
* previously dropped for QuickTime .mov (and any .mp4 carrying it).
118+
*/
119+
private void processQuickTimeKeys(@Nullable byte[] payload) {
120+
quickTimeMetadataKeys.clear();
121+
if (payload == null || payload.length < 8) {
122+
return;
123+
}
124+
//1 byte version + 3 bytes flags, then uint32 entry count
125+
int pos = 4;
126+
long entryCount = readUInt32(payload, pos);
127+
pos += 4;
128+
for (long i = 0; i < entryCount && pos + 8 <= payload.length; i++) {
129+
long keySize = readUInt32(payload, pos);
130+
if (keySize < 8 || pos + keySize > payload.length) {
131+
return;
132+
}
133+
//4 bytes key namespace, then the UTF-8 key name
134+
quickTimeMetadataKeys.add(
135+
new String(payload, pos + 8, (int) keySize - 8, StandardCharsets.UTF_8));
136+
pos += (int) keySize;
137+
}
138+
}
139+
140+
/**
141+
* Parses the QuickTime metadata 'ilst' box, whose entries are keyed by the
142+
* 1-based index into the preceding 'keys' box. Each entry holds a 'data' box
143+
* with the value. UTF-8 text and the numeric "well-known" value types are emitted
144+
* under their key name; other types (e.g. images, binary plists) are skipped.
145+
*/
146+
private void processQuickTimeItemList(@Nullable byte[] payload) {
147+
if (payload == null) {
148+
return;
149+
}
150+
int pos = 0;
151+
while (pos + 8 <= payload.length) {
152+
long entrySize = readUInt32(payload, pos);
153+
if (entrySize < 8 || pos + entrySize > payload.length) {
154+
return;
155+
}
156+
int index = (int) readUInt32(payload, pos + 4);
157+
int entryEnd = (int) (pos + entrySize);
158+
int data = pos + 8;
159+
//inner 'data' box: size(4) type(4) valueType(4) locale(4) value
160+
if (data + 16 <= entryEnd) {
161+
long dataSize = readUInt32(payload, data);
162+
boolean isData = payload[data + 4] == 'd' && payload[data + 5] == 'a'
163+
&& payload[data + 6] == 't' && payload[data + 7] == 'a';
164+
if (isData && dataSize >= 16 && data + dataSize <= entryEnd) {
165+
int valueType = (int) readUInt32(payload, data + 8);
166+
int valueLength = (int) dataSize - 16;
167+
if (index >= 1 && index <= quickTimeMetadataKeys.size()) {
168+
String key = quickTimeMetadataKeys.get(index - 1);
169+
String value = decodeValue(payload, data + 16, valueLength, valueType);
170+
if (value != null) {
171+
tikaMetadata.add(key, value);
172+
if (key.equals(QT_LOCATION_ISO6709)) {
173+
addLocation(value);
174+
}
175+
}
176+
}
177+
}
178+
}
179+
pos += (int) entrySize;
180+
}
181+
}
182+
183+
/**
184+
* Maps an ISO 6709 location string (latitude, longitude, optional altitude) to the
185+
* standard {@code geo:lat}/{@code geo:long}/{@code geo:alt} properties, in addition to
186+
* the raw value, so QuickTime location matches the {@code geo:*} output of the udta path.
187+
*/
188+
private void addLocation(String iso6709) {
189+
Matcher matcher = ISO6709_PATTERN.matcher(iso6709);
190+
if (matcher.find()) {
191+
tikaMetadata.set(TikaCoreProperties.LATITUDE, Double.parseDouble(matcher.group(1)));
192+
tikaMetadata.set(TikaCoreProperties.LONGITUDE, Double.parseDouble(matcher.group(2)));
193+
if (matcher.group(3) != null) {
194+
tikaMetadata.set(TikaCoreProperties.ALTITUDE, Double.parseDouble(matcher.group(3)));
195+
}
196+
}
197+
}
198+
199+
/**
200+
* Decodes a metadata item value of one of the QTFF "well-known" types to a string,
201+
* or returns null for types that are not handled (e.g. images or binary plists).
202+
* Integers may be 1 to 8 bytes wide (e.g. the live-photo.auto flag is a single byte).
203+
*/
204+
@Nullable
205+
private static String decodeValue(byte[] b, int off, int len, int valueType) {
206+
switch (valueType) {
207+
case QT_TEXT_TYPE:
208+
return new String(b, off, len, StandardCharsets.UTF_8);
209+
case QT_INT_BE_TYPE:
210+
case QT_UINT_BE_TYPE:
211+
if (len < 1 || len > 8) {
212+
return null;
213+
}
214+
byte[] intBytes = Arrays.copyOfRange(b, off, off + len);
215+
return valueType == QT_INT_BE_TYPE
216+
? new BigInteger(intBytes).toString()
217+
: new BigInteger(1, intBytes).toString();
218+
case QT_FLOAT32_TYPE:
219+
return len == 4 ? String.valueOf(ByteBuffer.wrap(b, off, len).getFloat()) : null;
220+
case QT_FLOAT64_TYPE:
221+
return len == 8 ? String.valueOf(ByteBuffer.wrap(b, off, len).getDouble()) : null;
222+
default:
223+
return null;
224+
}
225+
}
226+
227+
private static long readUInt32(byte[] b, int off) {
228+
return ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16)
229+
| ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL);
230+
}
79231
}

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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public class TikaUserDataBox {
4444
private static final String HDLR = "hdlr";
4545
private static final String MDIR = "mdir";//apple metadata itunes reader
4646
private static final Pattern COORDINATE_PATTERN =
47-
Pattern.compile("([+-]\\d+\\.\\d+)([+-]\\d+\\.\\d+)");
47+
Pattern.compile("([+-]\\d+\\.\\d+)([+-]\\d+\\.\\d+)([+-]\\d+(?:\\.\\d+)?)?");
4848

4949
@Nullable
5050
private String coordinateString;
@@ -270,6 +270,11 @@ public void addMetadata(Mp4Directory directory) {
270270
double longitude = Double.parseDouble(matcher.group(2));
271271
directory.setDouble(8193, latitude);
272272
directory.setDouble(8194, longitude);
273+
//Mp4Directory has no altitude tag, so set geo:alt directly
274+
if (matcher.group(3) != null) {
275+
metadata.set(TikaCoreProperties.ALTITUDE,
276+
Double.parseDouble(matcher.group(3)));
277+
}
273278
}
274279
}
275280
}

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,4 +281,42 @@ private Set<String> getVals(Metadata m, String k) {
281281
}
282282
return vals;
283283
} */
284+
285+
@Test
286+
public void testQuickTimeMetadataKeys() throws Exception {
287+
//QuickTime item-list metadata (moov/meta/keys+ilst, the com.apple.quicktime.*
288+
//keys such as the content identifier and ISO 6709 location) was previously
289+
//dropped by the MP4 handler. See TIKA-2861.
290+
Metadata metadata = new Metadata();
291+
getText("testMP4_QuickTimeMetadata.mov", metadata);
292+
assertEquals("TEST-UUID-0001-LIVEPHOTO",
293+
metadata.get("com.apple.quicktime.content.identifier"));
294+
295+
//the raw ISO 6709 location is preserved ...
296+
assertEquals("+12.3456-098.7654+010.500/",
297+
metadata.get("com.apple.quicktime.location.ISO6709"));
298+
//... and also mapped to the standard geo:* properties (incl. altitude)
299+
assertEquals(12.3456, Double.parseDouble(metadata.get(TikaCoreProperties.LATITUDE)), 0.00001);
300+
assertEquals(-98.7654, Double.parseDouble(metadata.get(TikaCoreProperties.LONGITUDE)), 0.00001);
301+
assertEquals(10.5, Double.parseDouble(metadata.get(TikaCoreProperties.ALTITUDE)), 0.00001);
302+
303+
//numeric well-known value types (uint8, float32, int32, float64)
304+
assertEquals("1", metadata.get("com.apple.quicktime.live-photo.auto"));
305+
assertEquals("0.75", metadata.get("com.apple.quicktime.live-photo.vitality-score"));
306+
assertEquals("-13",
307+
metadata.get("com.apple.quicktime.camera.focal_length.35mm_equivalent"));
308+
assertEquals("1.5",
309+
metadata.get("com.apple.quicktime.full-frame-rate-playback-intent"));
310+
}
311+
312+
@Test
313+
public void testUdtaLocation() throws Exception {
314+
//the udta "(c)xyz" ISO 6709 location is mapped to geo:lat/geo:long, and its
315+
//optional altitude, which was previously dropped, to geo:alt. See TIKA-2861.
316+
Metadata metadata = new Metadata();
317+
getText("testMP4_udtaLocation.mp4", metadata);
318+
assertEquals(12.3456, Double.parseDouble(metadata.get(TikaCoreProperties.LATITUDE)), 0.00001);
319+
assertEquals(-98.7654, Double.parseDouble(metadata.get(TikaCoreProperties.LONGITUDE)), 0.00001);
320+
assertEquals(10.5, Double.parseDouble(metadata.get(TikaCoreProperties.ALTITUDE)), 0.00001);
321+
}
284322
}

0 commit comments

Comments
 (0)