musikr.tag: parse mp4 fields

This commit is contained in:
Alexander Capehart 2024-12-23 16:46:56 -05:00
parent 6652e351cf
commit b6bc065a4a
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
3 changed files with 94 additions and 53 deletions

View file

@ -30,7 +30,7 @@ import org.oxycblt.musikr.util.positiveOrNull
* *
* @see transformPositionField * @see transformPositionField
*/ */
internal fun String.parseId3v2PositionField() = internal fun String.parseSlashPositionField() =
split('/', limit = 2).let { split('/', limit = 2).let {
transformPositionField(it[0].toIntOrNull(), it.getOrNull(1)?.toIntOrNull()) transformPositionField(it[0].toIntOrNull(), it.getOrNull(1)?.toIntOrNull())
} }

View file

@ -21,98 +21,113 @@ package org.oxycblt.musikr.tag.parse
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
import org.oxycblt.musikr.tag.Date import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.format.parseId3v2PositionField import org.oxycblt.musikr.tag.format.parseSlashPositionField
import org.oxycblt.musikr.tag.format.parseXiphPositionField import org.oxycblt.musikr.tag.format.parseXiphPositionField
import org.oxycblt.musikr.util.nonZeroOrNull import org.oxycblt.musikr.util.nonZeroOrNull
// Note: TagLibJNI deliberately uppercases descriptive tags to avoid casing issues,
// hence why the casing here is matched. Note that MP4 atoms are kept in their
// original casing, as they are case-sensitive.
// Song // Song
internal fun Metadata.musicBrainzId() = internal fun Metadata.musicBrainzId() =
(xiph["MUSICBRAINZ_RELEASETRACKID"] (xiph["MUSICBRAINZ_RELEASETRACKID"]
?: xiph["MUSICBRAINZ RELEASE TRACK ID"] ?: xiph["MUSICBRAINZ RELEASE TRACK ID"]
?: id3v2["TXXX:MUSICBRAINZ RELEASE TRACK ID"] ?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ RELEASE TRACK ID"]
?: id3v2["TXXX:MUSICBRAINZ_RELEASETRACKID"]) ?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ_RELEASETRACKID"])
?.first() ?: id3v2["TXXX:MUSICBRAINZ RELEASE TRACK ID"]
?: id3v2["TXXX:MUSICBRAINZ_RELEASETRACKID"]?.first()
internal fun Metadata.name() = (xiph["TITLE"] ?: id3v2["TIT2"])?.first() internal fun Metadata.name() =
(xiph["TITLE"] ?: mp4["©nam"] ?: mp4["©trk"] ?: id3v2["TIT2"])?.first()
internal fun Metadata.sortName() = (xiph["TITLESORT"] ?: id3v2["TSOT"])?.first() internal fun Metadata.sortName() = (xiph["TITLESORT"] ?: mp4["sonm"] ?: id3v2["TSOT"])?.first()
// Track. // Track.
internal fun Metadata.track() = internal fun Metadata.track() =
(parseXiphPositionField( (parseXiphPositionField(
xiph["TRACKNUMBER"]?.first(), xiph["TRACKNUMBER"]?.first(),
(xiph["TOTALTRACKS"] ?: xiph["TRACKTOTAL"] ?: xiph["TRACKC"])?.first()) (xiph["TOTALTRACKS"] ?: xiph["TRACKTOTAL"] ?: xiph["TRACKC"])?.first())
?: id3v2["TRCK"]?.run { first().parseId3v2PositionField() }) ?: (mp4["trkn"] ?: id3v2["TRCK"])?.run { first().parseSlashPositionField() })
// Disc and it's subtitle name. // Disc and it's subtitle name.
internal fun Metadata.disc() = internal fun Metadata.disc() =
(parseXiphPositionField( (parseXiphPositionField(
xiph["DISCNUMBER"]?.first(), xiph["DISCNUMBER"]?.first(),
(xiph["TOTALDISCS"] ?: xiph["DISCTOTAL"] ?: xiph["DISCC"])?.run { first() }) (xiph["TOTALDISCS"] ?: xiph["DISCTOTAL"] ?: xiph["DISCC"])?.run { first() })
?: id3v2["TPOS"]?.run { first().parseId3v2PositionField() }) ?: (mp4["disk"] ?: id3v2["TPOS"])?.run { first().parseSlashPositionField() })
internal fun Metadata.subtitle() = (xiph["DISCSUBTITLE"] ?: id3v2["TSST"])?.first() internal fun Metadata.subtitle() = (xiph["DISCSUBTITLE"] ?: id3v2["TSST"])?.first()
// Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
// date types.
// Our hierarchy for dates is as such:
// 1. ID3v2.4 Original Date, as it resolves the "Released in X, Remastered in Y" issue
// 2. ID3v2.4 Recording Date, as it is the most common date type
// 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type
// TODO: Show original and normal dates side-by-side
// TODO: Handle dates that are in "January" because the actual specific release date
// isn't known?
internal fun Metadata.date() = internal fun Metadata.date() =
(xiph["ORIGINALDATE"]?.run { Date.from(first()) } // For ID3v 2, are somewhat complicated, as not only did their semantics change from a flat
?: xiph["DATE"]?.run { Date.from(first()) } // year value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
?: xiph["YEAR"]?.run { Date.from(first()) } // date types.
?: // Our hierarchy for dates is as such:
// 1. ID3v2.4 Original Date, as it resolves the "Released in X, Remastered in Y" issue
// xiph dates are less complicated, but there are still several types // 2. ID3v2.4 Recording Date, as it is the most common date type
// Our hierarchy for dates is as such: // 3. ID3v2.4 Release Date, as it is the second most common date type
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue // 4. ID3v2.3 Original Date, as it is like #1
// 2. Date, as it is the most common date type // 5. ID3v2.3 Release Year, as it is the most common date type
// 3. Year, as old xiph tags tended to use this (I know this because it's the only // xiph dates aren't complicated, but there are still several types
// date tag that android supports, so it must be 15 years old or more!) // Our hierarchy for dates is as such:
id3v2["TDOR"]?.run { Date.from(first()) } // 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
?: id3v2["TDRC"]?.run { Date.from(first()) } // 2. Date, as it is the most common date type
?: id3v2["TDRL"]?.run { Date.from(first()) } // 3. Year, as old xiph tags tended to use this (I know this because it's the only
?: parseId3v23Date()) // date tag that android supports, so it must be 15 years old or more!)
// TODO: Show original and normal dates side-by-side
// TODO: Handle dates that are in "January" because the actual specific release date
// isn't known?
((xiph["ORIGINALDATE"]
?: xiph["DATE"]
?: xiph["YEAR"]
?: mp4["©day"]
?: id3v2["TDOR"]
?: id3v2["TDRC"]
?: id3v2["TDRL"])
?.run { Date.from(first()) } ?: parseId3v23Date())
// Album // Album
internal fun Metadata.albumMusicBrainzId() = internal fun Metadata.albumMusicBrainzId() =
(xiph["MUSICBRAINZ_ALBUMID"] (xiph["MUSICBRAINZ_ALBUMID"]
?: xiph["MUSICBRAINZ ALBUM ID"] ?: xiph["MUSICBRAINZ ALBUM ID"]
?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ ALBUM ID"]
?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ_ALBUMID"]
?: id3v2["TXXX:MUSICBRAINZ ALBUM ID"] ?: id3v2["TXXX:MUSICBRAINZ ALBUM ID"]
?: id3v2["TXXX:MUSICBRAINZ_ALBUMID"]) ?: id3v2["TXXX:MUSICBRAINZ_ALBUMID"])
?.first() ?.first()
internal fun Metadata.albumName() = (xiph["ALBUM"] ?: id3v2["TALB"])?.first() internal fun Metadata.albumName() = (xiph["ALBUM"] ?: mp4["©alb"] ?: id3v2["TALB"])?.first()
internal fun Metadata.albumSortName() = (xiph["ALBUMSORT"] ?: id3v2["TSOA"])?.first() internal fun Metadata.albumSortName() = (xiph["ALBUMSORT"] ?: mp4["soal"] ?: id3v2["TSOA"])?.first()
internal fun Metadata.releaseTypes() = internal fun Metadata.releaseTypes() =
// GRP/GRP1 are assumed to also pertain to release types.
// GRP1 is a non-standard iTunes extension.
(xiph["RELEASETYPE"] (xiph["RELEASETYPE"]
?: xiph["MUSICBRAINZ ALBUM TYPE"] ?: xiph["MUSICBRAINZ ALBUM TYPE"]
?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ ALBUM TYPE"]
?: mp4["----:COM.APPLE.ITUNES:RELEASETYPE"]
?: mp4["©grp"]
?: id3v2["TXXX:MUSICBRAINZ ALBUM TYPE"] ?: id3v2["TXXX:MUSICBRAINZ ALBUM TYPE"]
?: id3v2["TXXX:RELEASETYPE"] ?: id3v2["TXXX:RELEASETYPE"]
?: ?: id3v2["GRP1"])
// This is a non-standard iTunes extension
id3v2["GRP1"])
// Artist // Artist
internal fun Metadata.artistMusicBrainzIds() = internal fun Metadata.artistMusicBrainzIds() =
(xiph["MUSICBRAINZ_ARTISTID"] (xiph["MUSICBRAINZ_ARTISTID"]
?: xiph["MUSICBRAINZ ARTIST ID"] ?: xiph["MUSICBRAINZ ARTIST ID"]
?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ ARTIST ID"]
?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ_ARTISTID"]
?: id3v2["TXXX:MUSICBRAINZ ARTIST ID"] ?: id3v2["TXXX:MUSICBRAINZ ARTIST ID"]
?: id3v2["TXXX:MUSICBRAINZ_ARTISTID"]) ?: id3v2["TXXX:MUSICBRAINZ_ARTISTID"])
internal fun Metadata.artistNames() = internal fun Metadata.artistNames() =
(xiph["ARTISTS"] (xiph["ARTISTS"]
?: xiph["ARTIST"] ?: xiph["ARTIST"]
?: mp4["----:COM.APPLE.ITUNES:ARTISTS"]
?: mp4["©ART"]
?: mp4["----:COM.APPLE.ITUNES:ARTIST"]
?: id3v2["TXXX:ARTISTS"] ?: id3v2["TXXX:ARTISTS"]
?: id3v2["TPE1"] ?: id3v2["TPE1"]
?: id3v2["TXXX:ARTIST"]) ?: id3v2["TXXX:ARTIST"])
@ -123,6 +138,12 @@ internal fun Metadata.artistSortNames() =
?: xiph["ARTISTS SORT"] ?: xiph["ARTISTS SORT"]
?: xiph["ARTISTSORT"] ?: xiph["ARTISTSORT"]
?: xiph["ARTIST SORT"] ?: xiph["ARTIST SORT"]
?: mp4["----:COM.APPLE.ITUNES:ARTISTSSORT"]
?: mp4["----:COM.APPLE.ITUNES:ARTISTS_SORT"]
?: mp4["----:COM.APPLE.ITUNES:ARTISTS SORT"]
?: mp4["soar"]
?: mp4["----:COM.APPLE.ITUNES:ARTISTSORT"]
?: mp4["----:COM.APPLE.ITUNES:ARTIST SORT"]
?: id3v2["TXXX:ARTISTSSORT"] ?: id3v2["TXXX:ARTISTSSORT"]
?: id3v2["TXXX:ARTISTS_SORT"] ?: id3v2["TXXX:ARTISTS_SORT"]
?: id3v2["TXXX:ARTISTS SORT"] ?: id3v2["TXXX:ARTISTS SORT"]
@ -133,6 +154,8 @@ internal fun Metadata.artistSortNames() =
internal fun Metadata.albumArtistMusicBrainzIds() = internal fun Metadata.albumArtistMusicBrainzIds() =
(xiph["MUSICBRAINZ_ALBUMARTISTID"] (xiph["MUSICBRAINZ_ALBUMARTISTID"]
?: xiph["MUSICBRAINZ ALBUM ARTIST ID"] ?: xiph["MUSICBRAINZ ALBUM ARTIST ID"]
?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ ALBUM ARTIST ID"]
?: mp4["----:COM.APPLE.ITUNES:MUSICBRAINZ_ALBUMARTISTID"]
?: id3v2["TXXX:MUSICBRAINZ ALBUM ARTIST ID"] ?: id3v2["TXXX:MUSICBRAINZ ALBUM ARTIST ID"]
?: id3v2["TXXX:MUSICBRAINZ_ALBUMARTISTID"]) ?: id3v2["TXXX:MUSICBRAINZ_ALBUMARTISTID"])
@ -142,6 +165,12 @@ internal fun Metadata.albumArtistNames() =
?: xiph["ALBUM ARTISTS"] ?: xiph["ALBUM ARTISTS"]
?: xiph["ALBUMARTIST"] ?: xiph["ALBUMARTIST"]
?: xiph["ALBUM ARTIST"] ?: xiph["ALBUM ARTIST"]
?: mp4["----:COM.APPLE.ITUNES:ALBUMARTISTS"]
?: mp4["----:COM.APPLE.ITUNES:ALBUM_ARTISTS"]
?: mp4["----:COM.APPLE.ITUNES:ALBUM ARTISTS"]
?: mp4["aART"]
?: mp4["----:COM.APPLE.ITUNES:ALBUMARTIST"]
?: mp4["----:COM.APPLE.ITUNES:ALBUM ARTIST"]
?: id3v2["TXXX:ALBUMARTISTS"] ?: id3v2["TXXX:ALBUMARTISTS"]
?: id3v2["TXXX:ALBUM_ARTISTS"] ?: id3v2["TXXX:ALBUM_ARTISTS"]
?: id3v2["TXXX:ALBUM ARTISTS"] ?: id3v2["TXXX:ALBUM ARTISTS"]
@ -150,27 +179,37 @@ internal fun Metadata.albumArtistNames() =
?: id3v2["TXXX:ALBUM ARTIST"]) ?: id3v2["TXXX:ALBUM ARTIST"])
internal fun Metadata.albumArtistSortNames() = internal fun Metadata.albumArtistSortNames() =
// TSO2 is a non-standard iTunes extension.
(xiph["ALBUMARTISTSSORT"] (xiph["ALBUMARTISTSSORT"]
?: xiph["ALBUMARTISTS_SORT"] ?: xiph["ALBUMARTISTS_SORT"]
?: xiph["ALBUMARTISTS SORT"] ?: xiph["ALBUMARTISTS SORT"]
?: xiph["ALBUMARTISTSORT"] ?: xiph["ALBUMARTISTSORT"]
?: xiph["ALBUM ARTIST SORT"] ?: xiph["ALBUM ARTIST SORT"]
?: mp4["----:COM.APPLE.ITUNES:ALBUMARTISTSSORT"]
?: mp4["----:COM.APPLE.ITUNES:ALBUMARTISTS_SORT"]
?: mp4["----:COM.APPLE.ITUNES:ALBUMARTISTS SORT"]
?: mp4["----:COM.APPLE.ITUNES:ALBUMARTISTSORT"]
?: mp4["soaa"]
?: mp4["----:COM.APPLE.ITUNES:ALBUM ARTIST SORT"]
?: id3v2["TXXX:ALBUMARTISTSSORT"] ?: id3v2["TXXX:ALBUMARTISTSSORT"]
?: id3v2["TXXX:ALBUMARTISTS_SORT"] ?: id3v2["TXXX:ALBUMARTISTS_SORT"]
?: id3v2["TXXX:ALBUMARTISTS SORT"] ?: id3v2["TXXX:ALBUMARTISTS SORT"]
?: id3v2["TXXX:ALBUMARTISTSORT"] ?: id3v2["TXXX:ALBUMARTISTSORT"]
// This is a non-standard iTunes extension
?: id3v2["TSO2"] ?: id3v2["TSO2"]
?: id3v2["TXXX:ALBUM ARTIST SORT"]) ?: id3v2["TXXX:ALBUM ARTIST SORT"])
// Genre // Genre
internal fun Metadata.genreNames() = xiph["GENRE"] ?: id3v2["TCON"] internal fun Metadata.genreNames() = xiph["GENRE"] ?: mp4["©gen"] ?: mp4["gnre"] ?: id3v2["TCON"]
// Compilation Flag // Compilation Flag
internal fun Metadata.isCompilation() = internal fun Metadata.isCompilation() =
// TCMP is a non-standard itunes extension
(xiph["COMPILATION"] (xiph["COMPILATION"]
?: xiph["ITUNESCOMPILATION"] ?: xiph["ITUNESCOMPILATION"]
?: id3v2["TCMP"] // This is a non-standard itunes extension ?: mp4["cpil"]
?: mp4["----:COM.APPLE.ITUNES:COMPILATION"]
?: mp4["----:COM.APPLE.ITUNES:ITUNESCOMPILATION"]
?: id3v2["TCMP"]
?: id3v2["TXXX:COMPILATION"] ?: id3v2["TXXX:COMPILATION"]
?: id3v2["TXXX:ITUNESCOMPILATION"]) ?: id3v2["TXXX:ITUNESCOMPILATION"])
?.let { ?.let {
@ -182,11 +221,13 @@ internal fun Metadata.isCompilation() =
internal fun Metadata.replayGainTrackAdjustment() = internal fun Metadata.replayGainTrackAdjustment() =
(xiph["R128_TRACK_GAIN"]?.parseR128Adjustment() (xiph["R128_TRACK_GAIN"]?.parseR128Adjustment()
?: xiph["REPLAYGAIN_TRACK_GAIN"]?.parseReplayGainAdjustment() ?: xiph["REPLAYGAIN_TRACK_GAIN"]?.parseReplayGainAdjustment()
?: mp4["----:COM.APPLE.ITUNES:REPLAYGAIN_TRACK_GAIN"]?.parseReplayGainAdjustment()
?: id3v2["TXXX:REPLAYGAIN_TRACK_GAIN"]?.parseReplayGainAdjustment()) ?: id3v2["TXXX:REPLAYGAIN_TRACK_GAIN"]?.parseReplayGainAdjustment())
internal fun Metadata.replayGainAlbumAdjustment() = internal fun Metadata.replayGainAlbumAdjustment() =
(xiph["R128_ALBUM_GAIN"]?.parseR128Adjustment() (xiph["R128_ALBUM_GAIN"]?.parseR128Adjustment()
?: xiph["REPLAYGAIN_ALBUM_GAIN"]?.parseReplayGainAdjustment() ?: xiph["REPLAYGAIN_ALBUM_GAIN"]?.parseReplayGainAdjustment()
?: mp4["----:COM.APPLE.ITUNES:REPLAYGAIN_ALBUM_GAIN"]?.parseReplayGainAdjustment()
?: id3v2["TXXX:REPLAYGAIN_ALBUM_GAIN"]?.parseReplayGainAdjustment()) ?: id3v2["TXXX:REPLAYGAIN_ALBUM_GAIN"]?.parseReplayGainAdjustment())
private fun List<String>.parseR128Adjustment() = private fun List<String>.parseR128Adjustment() =

View file

@ -21,7 +21,7 @@ package org.oxycblt.musikr.tag.parse
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.oxycblt.musikr.tag.format.parseId3GenreNames import org.oxycblt.musikr.tag.format.parseId3GenreNames
import org.oxycblt.musikr.tag.format.parseId3v2PositionField import org.oxycblt.musikr.tag.format.parseSlashPositionField
import org.oxycblt.musikr.tag.format.parseXiphPositionField import org.oxycblt.musikr.tag.format.parseXiphPositionField
import org.oxycblt.musikr.util.correctWhitespace import org.oxycblt.musikr.util.correctWhitespace
import org.oxycblt.musikr.util.splitEscaped import org.oxycblt.musikr.util.splitEscaped
@ -78,21 +78,21 @@ class TagUtilTest {
@Test @Test
fun parseId3v2PositionField_correct() { fun parseId3v2PositionField_correct() {
assertEquals(16, "16/32".parseId3v2PositionField()) assertEquals(16, "16/32".parseSlashPositionField())
assertEquals(16, "16".parseId3v2PositionField()) assertEquals(16, "16".parseSlashPositionField())
} }
@Test @Test
fun parseId3v2PositionField_zeroed() { fun parseId3v2PositionField_zeroed() {
assertEquals(null, "0".parseId3v2PositionField()) assertEquals(null, "0".parseSlashPositionField())
assertEquals(0, "0/32".parseId3v2PositionField()) assertEquals(0, "0/32".parseSlashPositionField())
} }
@Test @Test
fun parseId3v2PositionField_wack() { fun parseId3v2PositionField_wack() {
assertEquals(16, "16/".parseId3v2PositionField()) assertEquals(16, "16/".parseSlashPositionField())
assertEquals(null, "a".parseId3v2PositionField()) assertEquals(null, "a".parseSlashPositionField())
assertEquals(null, "a/b".parseId3v2PositionField()) assertEquals(null, "a/b".parseSlashPositionField())
} }
@Test @Test