Skip to content

Commit d5786e3

Browse files
authored
TIKA-4779: Add audio:track-count and audio:disc-count, normalize n/total values (#2947)
* [TIKA-4779] Add audio:track-count and audio:disc-count, normalize n/total values The total number of tracks and discs is present in all common audio containers but was dropped: the MP4 parser read the second value of the trkn/disk atoms and discarded it, ID3 TRCK/TPOS "n/total" values were passed through unsplit, and the Vorbis TRACKTOTAL/DISCTOTAL comments were not mapped. XMPDM has no property for the totals and cannot be extended, so this adds a Tika-owned org.apache.tika.metadata.Audio interface (audio:track-count, audio:disc-count), following the pattern of Geographic and the Google properties from TIKA-4775. xmpDM:trackNumber and xmpDM:discNumber are declared as Integer properties but previously received the raw tag value, including the combined "n/total" form and non-numeric forms like vinyl "A1". They now only receive clean positive integers, parsed by a small NumberAndTotal value class (same shape as the recently added ISO6709 parser). Nothing is lost: the value exactly as tagged stays available under the new audio:raw-track-number and audio:raw-disc-number properties. The Ogg path also gains the previously unmapped DISCNUMBER comment, and explicit TRACKTOTAL/TOTALTRACKS/DISCTOTAL/TOTALDISCS comments win over the combined form. The existing testMP4.m4a fixture already carries the totals (track 1 of 42, disc 6 of 12); the ID3v2.4 fixture gains a TRCK "3/12" frame. * [TIKA-4779] Consume the declared length of the disk atom The disk branch read a fixed 6 bytes with no length guard, unlike the adjacent trkn branch. Some encoders pad the disk atom to 8 bytes like trkn, in which case totalRead diverged from the bytes actually consumed and every following ilst record was read misaligned. Read the 6 known bytes when at least that much is declared, then skip the remainder, so the walk always consumes exactly the declared length. The udta fixture gains an iTunes-style meta/mdir/ilst with a padded 8-byte disk atom followed by a title entry; without the guard the misalignment derails the remaining parse.
1 parent da26f2c commit d5786e3

11 files changed

Lines changed: 339 additions & 8 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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.metadata;
18+
19+
/**
20+
* Audio metadata properties that have no XMPDM equivalent. XMPDM defines
21+
* {@link XMPDM#TRACK_NUMBER} and {@link XMPDM#DISC_NUMBER} but no properties
22+
* for the totals, although the common audio containers all carry them.
23+
* See TIKA-4779.
24+
*
25+
* @since Apache Tika 4.0.0
26+
*/
27+
public interface Audio {
28+
29+
/**
30+
* Total number of tracks on the album / in the set
31+
* (MP4 'trkn' second value, ID3 TRCK "n/total", Vorbis TRACKTOTAL).
32+
*/
33+
Property TRACK_COUNT = Property.internalInteger("audio:track-count");
34+
35+
/**
36+
* Total number of discs in the set
37+
* (MP4 'disk' second value, ID3 TPOS "n/total", Vorbis DISCTOTAL).
38+
*/
39+
Property DISC_COUNT = Property.internalInteger("audio:disc-count");
40+
41+
/**
42+
* The track value exactly as tagged (e.g. "3/12" or a non-numeric form
43+
* like vinyl "A1"). {@link XMPDM#TRACK_NUMBER} only receives clean
44+
* integers, so nothing is lost.
45+
*/
46+
Property RAW_TRACK_NUMBER = Property.internalText("audio:raw-track-number");
47+
48+
/**
49+
* The disc value exactly as tagged, see {@link #RAW_TRACK_NUMBER}.
50+
*/
51+
Property RAW_DISC_NUMBER = Property.internalText("audio:raw-disc-number");
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
/**
20+
* The combined "n/total" form used by ID3 TRCK/TPOS frames and Vorbis
21+
* track/disc comments, e.g. {@code "3/12"}. See TIKA-4779.
22+
*/
23+
public final class NumberAndTotal {
24+
25+
//null when the part is absent or not a positive integer; non-numeric
26+
//forms (vinyl "A1") are preserved by the raw properties instead
27+
public final Integer number;
28+
public final Integer total;
29+
30+
NumberAndTotal(Integer number, Integer total) {
31+
this.number = number;
32+
this.total = total;
33+
}
34+
35+
/**
36+
* @param s a track or disc value, plain ("3") or combined ("3/12"), or null
37+
* @return the parsed value, or null if neither part is a positive integer
38+
*/
39+
public static NumberAndTotal parse(String s) {
40+
if (s == null) {
41+
return null;
42+
}
43+
int slash = s.indexOf('/');
44+
Integer number;
45+
Integer total;
46+
if (slash < 0) {
47+
number = positiveInteger(s);
48+
total = null;
49+
} else {
50+
number = positiveInteger(s.substring(0, slash));
51+
total = positiveInteger(s.substring(slash + 1));
52+
}
53+
if (number == null && total == null) {
54+
return null;
55+
}
56+
return new NumberAndTotal(number, total);
57+
}
58+
59+
private static Integer positiveInteger(String s) {
60+
try {
61+
int parsed = Integer.parseInt(s.trim());
62+
return parsed > 0 ? parsed : null;
63+
} catch (NumberFormatException e) {
64+
return null;
65+
}
66+
}
67+
}

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: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@
3030
import org.apache.tika.exception.TikaException;
3131
import org.apache.tika.io.TailStream;
3232
import org.apache.tika.io.TikaInputStream;
33+
import org.apache.tika.metadata.Audio;
3334
import org.apache.tika.metadata.Metadata;
3435
import org.apache.tika.metadata.TikaCoreProperties;
3536
import org.apache.tika.metadata.XMPDM;
3637
import org.apache.tika.mime.MediaType;
3738
import org.apache.tika.parser.ParseContext;
3839
import org.apache.tika.parser.Parser;
40+
import org.apache.tika.parser.audio.NumberAndTotal;
3941
import org.apache.tika.parser.mp3.ID3Tags.ID3Comment;
4042
import org.apache.tika.sax.XHTMLContentHandler;
4143

@@ -216,11 +218,29 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata
216218
sb.append(tag.getAlbum());
217219
if (tag.getTrackNumber() != null) {
218220
sb.append(", track ").append(tag.getTrackNumber());
219-
metadata.set(XMPDM.TRACK_NUMBER, tag.getTrackNumber());
221+
metadata.set(Audio.RAW_TRACK_NUMBER, tag.getTrackNumber());
222+
NumberAndTotal trackNumberAndTotal = NumberAndTotal.parse(tag.getTrackNumber());
223+
if (trackNumberAndTotal != null) {
224+
if (trackNumberAndTotal.number != null) {
225+
metadata.set(XMPDM.TRACK_NUMBER, trackNumberAndTotal.number);
226+
}
227+
if (trackNumberAndTotal.total != null) {
228+
metadata.set(Audio.TRACK_COUNT, trackNumberAndTotal.total);
229+
}
230+
}
220231
}
221232
if (tag.getDisc() != null) {
222233
sb.append(", disc ").append(tag.getDisc());
223-
metadata.set(XMPDM.DISC_NUMBER, tag.getDisc());
234+
metadata.set(Audio.RAW_DISC_NUMBER, tag.getDisc());
235+
NumberAndTotal discNumberAndTotal = NumberAndTotal.parse(tag.getDisc());
236+
if (discNumberAndTotal != null) {
237+
if (discNumberAndTotal.number != null) {
238+
metadata.set(XMPDM.DISC_NUMBER, discNumberAndTotal.number);
239+
}
240+
if (discNumberAndTotal.total != null) {
241+
metadata.set(Audio.DISC_COUNT, discNumberAndTotal.total);
242+
}
243+
}
224244
}
225245

226246
xhtml.element("h1", tag.getTitle());
@@ -268,4 +288,5 @@ private float durationSeconds() {
268288
return duration / 1000;
269289
}
270290
}
291+
271292
}

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: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.xml.sax.SAXException;
2828

2929
import org.apache.tika.exception.RuntimeSAXException;
30+
import org.apache.tika.metadata.Audio;
3031
import org.apache.tika.metadata.Metadata;
3132
import org.apache.tika.metadata.TikaCoreProperties;
3233
import org.apache.tika.metadata.XMP;
@@ -157,14 +158,29 @@ private void processIList(SequentialReader reader, long totalLen)
157158
long numA = reader.getUInt32();
158159
long numB = reader.getUInt32();
159160
metadata.set(XMPDM.TRACK_NUMBER, (int)numA);
161+
//2 bytes track total, 2 bytes reserved
162+
int trackCount = (int) (numB >>> 16);
163+
if (trackCount > 0) {
164+
metadata.set(Audio.TRACK_COUNT, trackCount);
165+
}
160166
} else {
161167
//log
162168
reader.skip(toRead);
163169
}
164170
} else if ("disk".equals(fieldName)) {
165-
int a = reader.getInt32();
166-
short b = reader.getInt16();
167-
metadata.set(XMPDM.DISC_NUMBER, a);
171+
//2 bytes reserved, 2 bytes disc, 2 bytes total; some encoders
172+
//pad to 8 bytes like trkn, so consume exactly toRead either way
173+
if (toRead >= 6) {
174+
int a = reader.getInt32();
175+
short b = reader.getInt16();
176+
metadata.set(XMPDM.DISC_NUMBER, a);
177+
if (b > 0) {
178+
metadata.set(Audio.DISC_COUNT, b);
179+
}
180+
reader.skip(toRead - 6);
181+
} else {
182+
reader.skip(toRead);
183+
}
168184
} else {
169185
String val = reader.getString(toRead, StandardCharsets.UTF_8);
170186
try {

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@
3232
import org.xml.sax.SAXException;
3333

3434
import org.apache.tika.exception.TikaException;
35+
import org.apache.tika.metadata.Audio;
3536
import org.apache.tika.metadata.Metadata;
3637
import org.apache.tika.metadata.TikaCoreProperties;
3738
import org.apache.tika.metadata.XMP;
3839
import org.apache.tika.metadata.XMPDM;
3940
import org.apache.tika.parser.AbstractParser;
41+
import org.apache.tika.parser.audio.NumberAndTotal;
4042
import org.apache.tika.sax.XHTMLContentHandler;
4143

4244
/**
@@ -46,6 +48,27 @@
4648
public abstract class OggAudioParser extends AbstractParser {
4749
private static final long serialVersionUID = 5168743829615945633L;
4850

51+
52+
/**
53+
* Returns the first positive integer found under the given comment keys,
54+
* or null if there is none.
55+
*/
56+
private static Integer firstPositiveInteger(VorbisStyleComments comments, String... keys) {
57+
for (String key : keys) {
58+
for (String value : comments.getComments(key)) {
59+
try {
60+
int parsed = Integer.parseInt(value.trim());
61+
if (parsed > 0) {
62+
return parsed;
63+
}
64+
} catch (NumberFormatException e) {
65+
//skip unparseable values
66+
}
67+
}
68+
}
69+
return null;
70+
}
71+
4972
protected static void extractChannelInfo(Metadata metadata, OggAudioInfoHeader info) {
5073
extractChannelInfo(metadata, info.getNumChannels());
5174
}
@@ -107,10 +130,40 @@ protected static void extractComments(Metadata metadata, XHTMLContentHandler xht
107130
// Album and Track number
108131
if (comments.getTrackNumber() != null) {
109132
xhtml.element("p", comments.getAlbum() + ", track " + comments.getTrackNumber());
110-
metadata.set(XMPDM.TRACK_NUMBER, comments.getTrackNumber());
133+
metadata.set(Audio.RAW_TRACK_NUMBER, comments.getTrackNumber());
134+
NumberAndTotal trackNumberAndTotal = NumberAndTotal.parse(comments.getTrackNumber());
135+
if (trackNumberAndTotal != null) {
136+
if (trackNumberAndTotal.number != null) {
137+
metadata.set(XMPDM.TRACK_NUMBER, trackNumberAndTotal.number);
138+
}
139+
if (trackNumberAndTotal.total != null) {
140+
metadata.set(Audio.TRACK_COUNT, trackNumberAndTotal.total);
141+
}
142+
}
111143
} else {
112144
xhtml.element("p", comments.getAlbum());
113145
}
146+
for (String discValue : comments.getComments("discnumber")) {
147+
metadata.set(Audio.RAW_DISC_NUMBER, discValue);
148+
NumberAndTotal discNumberAndTotal = NumberAndTotal.parse(discValue);
149+
if (discNumberAndTotal != null) {
150+
if (discNumberAndTotal.number != null) {
151+
metadata.set(XMPDM.DISC_NUMBER, discNumberAndTotal.number);
152+
}
153+
if (discNumberAndTotal.total != null) {
154+
metadata.set(Audio.DISC_COUNT, discNumberAndTotal.total);
155+
}
156+
}
157+
}
158+
//explicit totals win over the combined "n/total" form
159+
Integer trackTotal = firstPositiveInteger(comments, "tracktotal", "totaltracks");
160+
if (trackTotal != null) {
161+
metadata.set(Audio.TRACK_COUNT, trackTotal);
162+
}
163+
Integer discTotal = firstPositiveInteger(comments, "disctotal", "totaldiscs");
164+
if (discTotal != null) {
165+
metadata.set(Audio.DISC_COUNT, discTotal);
166+
}
114167

115168
// A few other bits
116169
xhtml.element("p", comments.getDate());
@@ -161,4 +214,5 @@ private static String formatDuration(double durationSeconds) {
161214
return String.format(Locale.ROOT, "%d:%02d", minutes, seconds);
162215
}
163216
}
217+
164218
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertNull;
21+
22+
import org.junit.jupiter.api.Test;
23+
24+
public class NumberAndTotalTest {
25+
26+
@Test
27+
public void testPlainNumber() {
28+
NumberAndTotal value = NumberAndTotal.parse("3");
29+
assertEquals(3, value.number);
30+
assertNull(value.total);
31+
}
32+
33+
@Test
34+
public void testCombinedForm() {
35+
NumberAndTotal value = NumberAndTotal.parse("3/12");
36+
assertEquals(3, value.number);
37+
assertEquals(12, value.total);
38+
}
39+
40+
@Test
41+
public void testWhitespace() {
42+
NumberAndTotal value = NumberAndTotal.parse(" 1 / 2 ");
43+
assertEquals(1, value.number);
44+
assertEquals(2, value.total);
45+
}
46+
47+
@Test
48+
public void testDegenerateForms() {
49+
assertNull(NumberAndTotal.parse(null));
50+
assertNull(NumberAndTotal.parse(" "));
51+
//non-numeric forms parse to nothing; the raw properties keep them
52+
assertNull(NumberAndTotal.parse("A1"));
53+
assertNull(NumberAndTotal.parse("3a/of twelve"));
54+
55+
NumberAndTotal totalOnly = NumberAndTotal.parse("/12");
56+
assertNull(totalOnly.number);
57+
assertEquals(12, totalOnly.total);
58+
59+
NumberAndTotal nonNumericTotal = NumberAndTotal.parse("3/of twelve");
60+
assertEquals(3, nonNumericTotal.number);
61+
assertNull(nonNumericTotal.total);
62+
63+
NumberAndTotal zeroTotal = NumberAndTotal.parse("3/0");
64+
assertEquals(3, zeroTotal.number);
65+
assertNull(zeroTotal.total);
66+
}
67+
}

0 commit comments

Comments
 (0)