From 96bc9e9652598d6ef899e9e3556bf5bdf3b8725b Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 29 Apr 2024 10:36:16 -0700 Subject: [PATCH] Propagate ID3 `TCON` frame to `MediaMetada.genre` This change also includes mapping the numeric ID3v1 codes to their string equivalents before setting them into `MediaMetadata`. This mapping already existed, but it was previously only used when parsing MP4 `gnre` atoms. Issue: androidx/media#1305 PiperOrigin-RevId: 629113480 --- RELEASENOTES.md | 2 + .../extractor/metadata/id3/Id3Util.java | 242 ++++++++++++++++++ .../metadata/id3/TextInformationFrame.java | 13 + .../media3/extractor/mp4/MetadataUtil.java | 212 +-------------- .../id3/Id3UtilTest.java} | 22 +- .../mp3/bear-id3-numeric-genre.mp3.dump | 1 + .../playbackdumps/mp3/bear-id3.mp3.dump | 1 + .../mp4/sample_with_metadata.mp4.dump | 1 + .../mp4/sample_with_numeric_genre.mp4.dump | 3 + 9 files changed, 281 insertions(+), 216 deletions(-) create mode 100644 libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/Id3Util.java rename libraries/extractor/src/test/java/androidx/media3/extractor/{mp4/MetadataUtilTest.java => metadata/id3/Id3UtilTest.java} (59%) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index baa8004f24..f4831003c2 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -27,6 +27,8 @@ ([#1302](https://github.com/androidx/media/issues/1302)). * Fix reading of MP4 (/iTunes) numeric `gnre` (genre) and `tmpo` (tempo) tags when the value is more than one byte long. + * Propagate ID3 `TCON` frame to `MediaMetadata.genre` + ([#1305](https://github.com/androidx/media/issues/1305)). * Image: * DRM: * Allow setting a `LoadErrorHandlingPolicy` on diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/Id3Util.java b/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/Id3Util.java new file mode 100644 index 0000000000..f26ad41acd --- /dev/null +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/Id3Util.java @@ -0,0 +1,242 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed 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 + * + * https://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 androidx.media3.extractor.metadata.id3; + +import androidx.annotation.Nullable; +import androidx.media3.common.util.UnstableApi; +import com.google.common.collect.ImmutableList; + +/** Utility methods for working with ID3 metadata. */ +@UnstableApi +public final class Id3Util { + + private static final ImmutableList STANDARD_GENRES = + ImmutableList.of( + // These are the official ID3v1 genres. + "Blues", + "Classic Rock", + "Country", + "Dance", + "Disco", + "Funk", + "Grunge", + "Hip-Hop", + "Jazz", + "Metal", + "New Age", + "Oldies", + "Other", + "Pop", + "R&B", + "Rap", + "Reggae", + "Rock", + "Techno", + "Industrial", + "Alternative", + "Ska", + "Death Metal", + "Pranks", + "Soundtrack", + "Euro-Techno", + "Ambient", + "Trip-Hop", + "Vocal", + "Jazz+Funk", + "Fusion", + "Trance", + "Classical", + "Instrumental", + "Acid", + "House", + "Game", + "Sound Clip", + "Gospel", + "Noise", + "AlternRock", + "Bass", + "Soul", + "Punk", + "Space", + "Meditative", + "Instrumental Pop", + "Instrumental Rock", + "Ethnic", + "Gothic", + "Darkwave", + "Techno-Industrial", + "Electronic", + "Pop-Folk", + "Eurodance", + "Dream", + "Southern Rock", + "Comedy", + "Cult", + "Gangsta", + "Top 40", + "Christian Rap", + "Pop/Funk", + "Jungle", + "Native American", + "Cabaret", + "New Wave", + "Psychadelic", + "Rave", + "Showtunes", + "Trailer", + "Lo-Fi", + "Tribal", + "Acid Punk", + "Acid Jazz", + "Polka", + "Retro", + "Musical", + "Rock & Roll", + "Hard Rock", + // Genres made up by the authors of Winamp (v1.91) and later added to the ID3 spec. + "Folk", + "Folk-Rock", + "National Folk", + "Swing", + "Fast Fusion", + "Bebob", + "Latin", + "Revival", + "Celtic", + "Bluegrass", + "Avantgarde", + "Gothic Rock", + "Progressive Rock", + "Psychedelic Rock", + "Symphonic Rock", + "Slow Rock", + "Big Band", + "Chorus", + "Easy Listening", + "Acoustic", + "Humour", + "Speech", + "Chanson", + "Opera", + "Chamber Music", + "Sonata", + "Symphony", + "Booty Bass", + "Primus", + "Porn Groove", + "Satire", + "Slow Jam", + "Club", + "Tango", + "Samba", + "Folklore", + "Ballad", + "Power Ballad", + "Rhythmic Soul", + "Freestyle", + "Duet", + "Punk Rock", + "Drum Solo", + "A capella", + "Euro-House", + "Dance Hall", + // Genres made up by the authors of Winamp (v1.91) but have not been added to the ID3 + // spec. + "Goa", + "Drum & Bass", + "Club-House", + "Hardcore", + "Terror", + "Indie", + "BritPop", + "Afro-Punk", + "Polsk Punk", + "Beat", + "Christian Gangsta Rap", + "Heavy Metal", + "Black Metal", + "Crossover", + "Contemporary Christian", + "Christian Rock", + "Merengue", + "Salsa", + "Thrash Metal", + "Anime", + "Jpop", + "Synthpop", + // Genres made up by the authors of Winamp (v5.6) but have not been added to the ID3 spec. + "Abstract", + "Art Rock", + "Baroque", + "Bhangra", + "Big beat", + "Breakbeat", + "Chillout", + "Downtempo", + "Dub", + "EBM", + "Eclectic", + "Electro", + "Electroclash", + "Emo", + "Experimental", + "Garage", + "Global", + "IDM", + "Illbient", + "Industro-Goth", + "Jam Band", + "Krautrock", + "Leftfield", + "Lounge", + "Math Rock", + "New Romantic", + "Nu-Breakz", + "Post-Punk", + "Post-Rock", + "Psytrance", + "Shoegaze", + "Space Rock", + "Trop Rock", + "World Music", + "Neoclassical", + "Audiobook", + "Audio theatre", + "Neue Deutsche Welle", + "Podcast", + "Indie-Rock", + "G-Funk", + "Dubstep", + "Garage Rock", + "Psybient"); + + /** + * Resolves an ID3v1 numeric genre code to its string form, or {@code null} if the code isn't + * recognized. + * + *

Includes codes that were added later by various versions of Winamp. See this Wikipedia list of official and unofficial ID3v1 + * genres. + */ + @Nullable + public static String resolveV1Genre(int id3v1GenreCode) { + return id3v1GenreCode >= 0 && id3v1GenreCode < STANDARD_GENRES.size() + ? STANDARD_GENRES.get(id3v1GenreCode) + : null; + } + + private Id3Util() {} +} diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/TextInformationFrame.java b/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/TextInformationFrame.java index 81384dbf67..25f977ebe9 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/TextInformationFrame.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/TextInformationFrame.java @@ -25,6 +25,7 @@ import androidx.media3.common.MediaMetadata; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Ints; import com.google.errorprone.annotations.InlineMe; import java.util.ArrayList; import java.util.List; @@ -175,6 +176,18 @@ public final class TextInformationFrame extends Id3Frame { case "TEXT": builder.setWriter(values.get(0)); break; + case "TCON": + @Nullable Integer genreCode = Ints.tryParse(values.get(0)); + if (genreCode == null) { + builder.setGenre(values.get(0)); + break; + } + @Nullable String genre = Id3Util.resolveV1Genre(genreCode); + if (genre != null) { + builder.setGenre(genre); + } + // Don't set a numeric genre that we don't recognize. + break; default: break; } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/MetadataUtil.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/MetadataUtil.java index 9216081464..06d4e637a5 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/MetadataUtil.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/MetadataUtil.java @@ -18,7 +18,6 @@ package androidx.media3.extractor.mp4; import static java.lang.Math.min; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.Metadata; @@ -30,6 +29,7 @@ import androidx.media3.extractor.GaplessInfoHolder; import androidx.media3.extractor.metadata.id3.ApicFrame; import androidx.media3.extractor.metadata.id3.CommentFrame; import androidx.media3.extractor.metadata.id3.Id3Frame; +import androidx.media3.extractor.metadata.id3.Id3Util; import androidx.media3.extractor.metadata.id3.InternalFrame; import androidx.media3.extractor.metadata.id3.TextInformationFrame; import com.google.common.collect.ImmutableList; @@ -78,208 +78,6 @@ import com.google.common.collect.ImmutableList; private static final int PICTURE_TYPE_FRONT_COVER = 3; - // Standard genres. - @VisibleForTesting - /* package */ static final String[] STANDARD_GENRES = - new String[] { - // These are the official ID3v1 genres. - "Blues", - "Classic Rock", - "Country", - "Dance", - "Disco", - "Funk", - "Grunge", - "Hip-Hop", - "Jazz", - "Metal", - "New Age", - "Oldies", - "Other", - "Pop", - "R&B", - "Rap", - "Reggae", - "Rock", - "Techno", - "Industrial", - "Alternative", - "Ska", - "Death Metal", - "Pranks", - "Soundtrack", - "Euro-Techno", - "Ambient", - "Trip-Hop", - "Vocal", - "Jazz+Funk", - "Fusion", - "Trance", - "Classical", - "Instrumental", - "Acid", - "House", - "Game", - "Sound Clip", - "Gospel", - "Noise", - "AlternRock", - "Bass", - "Soul", - "Punk", - "Space", - "Meditative", - "Instrumental Pop", - "Instrumental Rock", - "Ethnic", - "Gothic", - "Darkwave", - "Techno-Industrial", - "Electronic", - "Pop-Folk", - "Eurodance", - "Dream", - "Southern Rock", - "Comedy", - "Cult", - "Gangsta", - "Top 40", - "Christian Rap", - "Pop/Funk", - "Jungle", - "Native American", - "Cabaret", - "New Wave", - "Psychadelic", - "Rave", - "Showtunes", - "Trailer", - "Lo-Fi", - "Tribal", - "Acid Punk", - "Acid Jazz", - "Polka", - "Retro", - "Musical", - "Rock & Roll", - "Hard Rock", - // Genres made up by the authors of Winamp (v1.91) and later added to the ID3 spec. - "Folk", - "Folk-Rock", - "National Folk", - "Swing", - "Fast Fusion", - "Bebob", - "Latin", - "Revival", - "Celtic", - "Bluegrass", - "Avantgarde", - "Gothic Rock", - "Progressive Rock", - "Psychedelic Rock", - "Symphonic Rock", - "Slow Rock", - "Big Band", - "Chorus", - "Easy Listening", - "Acoustic", - "Humour", - "Speech", - "Chanson", - "Opera", - "Chamber Music", - "Sonata", - "Symphony", - "Booty Bass", - "Primus", - "Porn Groove", - "Satire", - "Slow Jam", - "Club", - "Tango", - "Samba", - "Folklore", - "Ballad", - "Power Ballad", - "Rhythmic Soul", - "Freestyle", - "Duet", - "Punk Rock", - "Drum Solo", - "A capella", - "Euro-House", - "Dance Hall", - // Genres made up by the authors of Winamp (v1.91) but have not been added to the ID3 spec. - "Goa", - "Drum & Bass", - "Club-House", - "Hardcore", - "Terror", - "Indie", - "BritPop", - "Afro-Punk", - "Polsk Punk", - "Beat", - "Christian Gangsta Rap", - "Heavy Metal", - "Black Metal", - "Crossover", - "Contemporary Christian", - "Christian Rock", - "Merengue", - "Salsa", - "Thrash Metal", - "Anime", - "Jpop", - "Synthpop", - // Genres made up by the authors of Winamp (v5.6) but have not been added to the ID3 spec. - "Abstract", - "Art Rock", - "Baroque", - "Bhangra", - "Big beat", - "Breakbeat", - "Chillout", - "Downtempo", - "Dub", - "EBM", - "Eclectic", - "Electro", - "Electroclash", - "Emo", - "Experimental", - "Garage", - "Global", - "IDM", - "Illbient", - "Industro-Goth", - "Jam Band", - "Krautrock", - "Leftfield", - "Lounge", - "Math Rock", - "New Romantic", - "Nu-Breakz", - "Post-Punk", - "Post-Rock", - "Psytrance", - "Shoegaze", - "Space Rock", - "Trop Rock", - "World Music", - "Neoclassical", - "Audiobook", - "Audio theatre", - "Neue Deutsche Welle", - "Podcast", - "Indie-Rock", - "G-Funk", - "Dubstep", - "Garage Rock", - "Psybient" - }; - private static final int TYPE_TOP_BYTE_COPYRIGHT = 0xA9; private static final int TYPE_TOP_BYTE_REPLACEMENT = 0xFD; // Truncated value of \uFFFD. @@ -535,11 +333,9 @@ import com.google.common.collect.ImmutableList; @Nullable private static TextInformationFrame parseStandardGenreAttribute(ParsableByteArray data) { int genreCode = parseIntegerAttribute(data); - @Nullable - String genreString = - (0 < genreCode && genreCode <= STANDARD_GENRES.length) - ? STANDARD_GENRES[genreCode - 1] - : null; + // ID3 tags are zero-indexed, but MP4 gnre codes are 1-indexed (the list of genres is otherwise + // the same). + @Nullable String genreString = Id3Util.resolveV1Genre(genreCode - 1); if (genreString != null) { return new TextInformationFrame( "TCON", /* description= */ null, ImmutableList.of(genreString)); diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/MetadataUtilTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/Id3UtilTest.java similarity index 59% rename from libraries/extractor/src/test/java/androidx/media3/extractor/mp4/MetadataUtilTest.java rename to libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/Id3UtilTest.java index 26757bba62..f3ab08cde9 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/MetadataUtilTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/Id3UtilTest.java @@ -1,11 +1,11 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed 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 + * https://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, @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package androidx.media3.extractor.mp4; +package androidx.media3.extractor.metadata.id3; import static com.google.common.truth.Truth.assertThat; @@ -21,13 +21,19 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; -/** Test for {@link MetadataUtil}. */ @RunWith(AndroidJUnit4.class) -public final class MetadataUtilTest { +public final class Id3UtilTest { @Test - public void standardGenre_length_matchesNumberOfId3Genres() { - // Check that we haven't forgotten a genre in the list. - assertThat(MetadataUtil.STANDARD_GENRES).hasLength(192); + public void expectedNumberOfV1Genres() { + for (int i = 0; i < 192; i++) { + assertThat(Id3Util.resolveV1Genre(i)).isNotNull(); + } + } + + @Test + public void unrecognizedV1Genre_returnsNull() { + assertThat(Id3Util.resolveV1Genre(-1)).isNull(); + assertThat(Id3Util.resolveV1Genre(200)).isNull(); } } diff --git a/libraries/test_data/src/test/assets/playbackdumps/mp3/bear-id3-numeric-genre.mp3.dump b/libraries/test_data/src/test/assets/playbackdumps/mp3/bear-id3-numeric-genre.mp3.dump index 06ac116f16..7bb8b0ea83 100644 --- a/libraries/test_data/src/test/assets/playbackdumps/mp3/bear-id3-numeric-genre.mp3.dump +++ b/libraries/test_data/src/test/assets/playbackdumps/mp3/bear-id3-numeric-genre.mp3.dump @@ -1190,3 +1190,4 @@ Listener.onMediaMetadata: albumTitle = Test Album artworkData = length 38946, hash 87684827 artworkDataType = other + genre = Metal diff --git a/libraries/test_data/src/test/assets/playbackdumps/mp3/bear-id3.mp3.dump b/libraries/test_data/src/test/assets/playbackdumps/mp3/bear-id3.mp3.dump index 06ac116f16..9426cd6fe2 100644 --- a/libraries/test_data/src/test/assets/playbackdumps/mp3/bear-id3.mp3.dump +++ b/libraries/test_data/src/test/assets/playbackdumps/mp3/bear-id3.mp3.dump @@ -1190,3 +1190,4 @@ Listener.onMediaMetadata: albumTitle = Test Album artworkData = length 38946, hash 87684827 artworkDataType = other + genre = Gorpcore diff --git a/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_with_metadata.mp4.dump b/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_with_metadata.mp4.dump index a37b0b1045..f21f7b600c 100644 --- a/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_with_metadata.mp4.dump +++ b/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_with_metadata.mp4.dump @@ -689,3 +689,4 @@ Listener.onMediaMetadata: trackNumber = 2 totalTrackCount = 12 recordingYear = 2024 + genre = Gorpcore diff --git a/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_with_numeric_genre.mp4.dump b/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_with_numeric_genre.mp4.dump index 03a9d2793d..e53019d1d7 100644 --- a/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_with_numeric_genre.mp4.dump +++ b/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_with_numeric_genre.mp4.dump @@ -682,3 +682,6 @@ AudioSink: buffer #44: time = 1000001065678 data = 1 +Listener.onMediaMetadata: + MediaMetadata[0]: + genre = Metal