musikr: start using ktaglib

This commit is contained in:
Alexander Capehart 2024-12-13 13:06:19 -07:00
parent 2f98d67855
commit 65151e006f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
13 changed files with 114 additions and 713 deletions

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.cover
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface CoverModule {
@Binds fun coverParser(impl: CoverParserImpl): CoverParser
}

View file

@ -1,69 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverParser.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.cover
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
import javax.inject.Inject
import org.oxycblt.musikr.metadata.AudioMetadata
interface CoverParser {
suspend fun extract(metadata: AudioMetadata): ByteArray?
}
class CoverParserImpl @Inject constructor() : CoverParser {
override suspend fun extract(metadata: AudioMetadata): ByteArray? {
val exoPlayerMetadata = metadata.exoPlayerFormat?.metadata
return exoPlayerMetadata?.let(::findCoverDataInMetadata)
?: metadata.mediaMetadataRetriever.embeddedPicture
}
private fun findCoverDataInMetadata(metadata: Metadata): ByteArray? {
var fallbackPic: ByteArray? = null
for (i in 0 until metadata.length()) {
// We can only extract pictures from two tags with this method, ID3v2's APIC or
// Vorbis picture comments.
val pic: ByteArray?
val type: Int
when (val entry = metadata.get(i)) {
is ApicFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
is PictureFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
else -> continue
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
return pic
} else if (fallbackPic == null) {
fallbackPic = pic
}
}
return fallbackPic
}
}

View file

@ -1,27 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* AudioMetadata.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.metadata
import android.media.MediaMetadataRetriever
import androidx.media3.common.Format
data class AudioMetadata(
val exoPlayerFormat: Format?,
val mediaMetadataRetriever: MediaMetadataRetriever
)

View file

@ -19,43 +19,24 @@
package org.oxycblt.musikr.metadata package org.oxycblt.musikr.metadata
import android.content.Context import android.content.Context
import android.media.MediaMetadataRetriever
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.ktaglib.FileRef
import org.oxycblt.ktaglib.KTagLib
import org.oxycblt.ktaglib.Metadata
import org.oxycblt.musikr.fs.query.DeviceFile import org.oxycblt.musikr.fs.query.DeviceFile
interface MetadataExtractor { interface MetadataExtractor {
suspend fun extract(file: DeviceFile): AudioMetadata suspend fun extract(file: DeviceFile): Metadata?
} }
class MetadataExtractorImpl class MetadataExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) :
@Inject MetadataExtractor {
constructor( override suspend fun extract(file: DeviceFile) =
@ApplicationContext private val context: Context, withContext(Dispatchers.IO) {
private val mediaSourceFactory: MediaSource.Factory KTagLib.open(context, FileRef(unlikelyToBeNull(file.path.name), file.uri))
) : MetadataExtractor {
override suspend fun extract(file: DeviceFile): AudioMetadata {
val exoPlayerMetadataFuture =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(file.uri))
val mediaMetadataRetriever =
MediaMetadataRetriever().apply {
withContext(Dispatchers.IO) { setDataSource(context, file.uri) }
}
val trackGroupArray = exoPlayerMetadataFuture.await()
if (trackGroupArray.isEmpty) {
return AudioMetadata(null, mediaMetadataRetriever)
} }
val trackGroup = trackGroupArray.get(0)
if (trackGroup.length == 0) {
return AudioMetadata(null, mediaMetadataRetriever)
}
val format = trackGroup.getFormat(0)
return AudioMetadata(format, mediaMetadataRetriever)
}
} }

View file

@ -1,198 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* ReusableMetadataRetriever.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.metadata
import android.os.Handler
import android.os.HandlerThread
import android.os.Message
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Timeline
import androidx.media3.common.util.Clock
import androidx.media3.common.util.HandlerWrapper
import androidx.media3.exoplayer.LoadingInfo
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.source.MediaPeriod
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.upstream.Allocator
import androidx.media3.exoplayer.upstream.DefaultAllocator
import com.google.common.util.concurrent.SettableFuture
import java.util.concurrent.Future
import javax.inject.Inject
import timber.log.Timber
private const val MESSAGE_PREPARE = 0
private const val MESSAGE_CONTINUE_LOADING = 1
private const val MESSAGE_CHECK_FAILURE = 2
private const val MESSAGE_RELEASE = 3
private const val CHECK_INTERVAL_MS = 100
// TODO: Rewrite and re-integrate
interface MetadataRetrieverExt {
fun retrieveMetadata(mediaItem: MediaItem): Future<TrackGroupArray>
fun retrieve()
interface Factory {
fun create(): MetadataRetrieverExt
}
}
class ReusableMetadataRetrieverImpl
@Inject
constructor(private val mediaSourceFactory: MediaSource.Factory) :
MetadataRetrieverExt, Handler.Callback {
private val mediaSourceThread = HandlerThread("Auxio:ChunkedMetadataRetriever:${hashCode()}")
private val mediaSourceHandler: HandlerWrapper
private var job: MetadataJob? = null
private data class JobParams(
val mediaItem: MediaItem,
val future: SettableFuture<TrackGroupArray>
)
private class JobData(
val params: JobParams,
val mediaSource: MediaSource,
var mediaPeriod: MediaPeriod?,
)
private class MetadataJob(val data: JobData, val mediaSourceCaller: MediaSourceCaller)
init {
mediaSourceThread.start()
mediaSourceHandler = Clock.DEFAULT.createHandler(mediaSourceThread.looper, this)
}
override fun retrieveMetadata(mediaItem: MediaItem): Future<TrackGroupArray> {
val job = job
check(job == null || job.data.params.future.isDone) { "Already working on something: $job" }
val future = SettableFuture.create<TrackGroupArray>()
mediaSourceHandler
.obtainMessage(MESSAGE_PREPARE, JobParams(mediaItem, future))
.sendToTarget()
return future
}
override fun retrieve() {
mediaSourceHandler.removeCallbacksAndMessages(null)
mediaSourceThread.quit()
}
override fun handleMessage(msg: Message): Boolean {
when (msg.what) {
MESSAGE_PREPARE -> {
val params = msg.obj as JobParams
val mediaSource = mediaSourceFactory.createMediaSource(params.mediaItem)
val data = JobData(params, mediaSource, null)
val mediaSourceCaller = MediaSourceCaller(data)
mediaSource.prepareSource(
mediaSourceCaller, /* mediaTransferListener= */ null, PlayerId.UNSET)
job = MetadataJob(data, mediaSourceCaller)
mediaSourceHandler.sendEmptyMessageDelayed(
MESSAGE_CHECK_FAILURE, /* delayMs= */ CHECK_INTERVAL_MS)
return true
}
MESSAGE_CONTINUE_LOADING -> {
val job = job ?: return true
checkNotNull(job.data.mediaPeriod)
.continueLoading(LoadingInfo.Builder().setPlaybackPositionUs(0).build())
return true
}
MESSAGE_CHECK_FAILURE -> {
val job = job ?: return true
val mediaPeriod = job.data.mediaPeriod
val mediaSource = job.data.mediaSource
val mediaSourceCaller = job.mediaSourceCaller
try {
if (mediaPeriod == null) {
mediaSource.maybeThrowSourceInfoRefreshError()
} else {
mediaPeriod.maybeThrowPrepareError()
}
} catch (e: Exception) {
Timber.e("Failed to extract MediaSource")
Timber.e(e.stackTraceToString())
mediaPeriod?.let(mediaSource::releasePeriod)
mediaSource.releaseSource(mediaSourceCaller)
job.data.params.future.setException(e)
}
return true
}
MESSAGE_RELEASE -> {
val job = job ?: return true
val mediaPeriod = job.data.mediaPeriod
val mediaSource = job.data.mediaSource
val mediaSourceCaller = job.mediaSourceCaller
mediaPeriod?.let { mediaSource.releasePeriod(it) }
mediaSource.releaseSource(mediaSourceCaller)
this.job = null
return true
}
else -> return false
}
}
private inner class MediaSourceCaller(private val data: JobData) :
MediaSource.MediaSourceCaller {
private val mediaPeriodCallback: MediaPeriodCallback =
MediaPeriodCallback(data.params.future)
private val allocator: Allocator =
DefaultAllocator(
/* trimOnReset= */ true,
/* individualAllocationSize= */ C.DEFAULT_BUFFER_SEGMENT_SIZE)
private var mediaPeriodCreated = false
override fun onSourceInfoRefreshed(source: MediaSource, timeline: Timeline) {
if (mediaPeriodCreated) {
// Ignore dynamic updates.
return
}
mediaPeriodCreated = true
val mediaPeriod =
source.createPeriod(
MediaSource.MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ 0)),
allocator,
/* startPositionUs= */ 0)
data.mediaPeriod = mediaPeriod
mediaPeriod.prepare(mediaPeriodCallback, /* positionUs= */ 0)
}
private inner class MediaPeriodCallback(
private val future: SettableFuture<TrackGroupArray>
) : MediaPeriod.Callback {
override fun onPrepared(mediaPeriod: MediaPeriod) {
future.set(mediaPeriod.getTrackGroups())
mediaSourceHandler.sendEmptyMessage(MESSAGE_RELEASE)
}
@Override
override fun onContinueLoadingRequested(source: MediaPeriod) {
mediaSourceHandler.sendEmptyMessage(MESSAGE_CONTINUE_LOADING)
}
}
}
}

View file

@ -26,15 +26,16 @@ import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import org.oxycblt.musikr.Storage import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.CachedSong import org.oxycblt.musikr.cache.CachedSong
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverParser
import org.oxycblt.musikr.fs.query.DeviceFile import org.oxycblt.musikr.fs.query.DeviceFile
import org.oxycblt.musikr.metadata.MetadataExtractor import org.oxycblt.musikr.metadata.MetadataExtractor
import org.oxycblt.musikr.tag.parse.ParsedTags import org.oxycblt.musikr.tag.parse.ParsedTags
import org.oxycblt.musikr.tag.parse.TagParser import org.oxycblt.musikr.tag.parse.TagParser
import timber.log.Timber as L
interface ExtractStep { interface ExtractStep {
fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic> fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic>
@ -42,11 +43,8 @@ interface ExtractStep {
class ExtractStepImpl class ExtractStepImpl
@Inject @Inject
constructor( constructor(private val metadataExtractor: MetadataExtractor, private val tagParser: TagParser) :
private val metadataExtractor: MetadataExtractor, ExtractStep {
private val tagParser: TagParser,
private val coverParser: CoverParser
) : ExtractStep {
override fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic> { override fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
val cacheResults = val cacheResults =
nodes nodes
@ -63,15 +61,16 @@ constructor(
ExtractedMusic.Song(it.file, song.parsedTags, song.cover) ExtractedMusic.Song(it.file, song.parsedTags, song.cover)
} }
} }
val split = uncachedSongs.distribute(8) val split = uncachedSongs.distribute(16)
val extractedSongs = val extractedSongs =
Array(split.hot.size) { i -> Array(split.hot.size) { i ->
split.hot[i] split.hot[i]
.map { node -> .mapNotNull { node ->
val metadata = metadataExtractor.extract(node.file) val metadata =
metadataExtractor.extract(node.file) ?: return@mapNotNull null
L.d("Extracted tags for ${metadata.id3v2}")
val tags = tagParser.parse(node.file, metadata) val tags = tagParser.parse(node.file, metadata)
val coverData = coverParser.extract(metadata) val cover = metadata.cover?.let { storage.storedCovers.write(it) }
val cover = coverData?.let { storage.storedCovers.write(it) }
ExtractedMusic.Song(node.file, tags, cover) ExtractedMusic.Song(node.file, tags, cover)
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)

View file

@ -20,37 +20,38 @@ package org.oxycblt.musikr.tag.parse
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.ktaglib.Metadata
import org.oxycblt.musikr.tag.Date import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.util.parseId3v2PositionField import org.oxycblt.musikr.tag.util.parseId3v2PositionField
import org.oxycblt.musikr.tag.util.parseVorbisPositionField import org.oxycblt.musikr.tag.util.parseXiphPositionField
// Song // Song
fun ExoPlayerTags.musicBrainzId() = fun Metadata.musicBrainzId() =
(vorbis["musicbrainz_releasetrackid"] (xiph["musicbrainz_releasetrackid"]
?: vorbis["musicbrainz release track id"] ?: xiph["musicbrainz release track id"]
?: id3v2["TXXX:musicbrainz release track id"] ?: id3v2["TXXX:musicbrainz release track id"]
?: id3v2["TXXX:musicbrainz_releasetrackid"]) ?: id3v2["TXXX:musicbrainz_releasetrackid"])
?.first() ?.first()
fun ExoPlayerTags.name() = (vorbis["title"] ?: id3v2["TIT2"])?.first() fun Metadata.name() = (xiph["title"] ?: id3v2["TIT2"])?.first()
fun ExoPlayerTags.sortName() = (vorbis["titlesort"] ?: id3v2["TSOT"])?.first() fun Metadata.sortName() = (xiph["titlesort"] ?: id3v2["TSOT"])?.first()
// Track. // Track.
fun ExoPlayerTags.track() = fun Metadata.track() =
(parseVorbisPositionField( (parseXiphPositionField(
vorbis["tracknumber"]?.first(), xiph["tracknumber"]?.first(),
(vorbis["totaltracks"] ?: vorbis["tracktotal"] ?: vorbis["trackc"])?.first()) (xiph["totaltracks"] ?: xiph["tracktotal"] ?: xiph["trackc"])?.first())
?: id3v2["TRCK"]?.run { first().parseId3v2PositionField() }) ?: id3v2["TRCK"]?.run { first().parseId3v2PositionField() })
// Disc and it's subtitle name. // Disc and it's subtitle name.
fun ExoPlayerTags.disc() = fun Metadata.disc() =
(parseVorbisPositionField( (parseXiphPositionField(
vorbis["discnumber"]?.first(), xiph["discnumber"]?.first(),
(vorbis["totaldiscs"] ?: vorbis["disctotal"] ?: vorbis["discc"])?.run { first() }) (xiph["totaldiscs"] ?: xiph["disctotal"] ?: xiph["discc"])?.run { first() })
?: id3v2["TPOS"]?.run { first().parseId3v2PositionField() }) ?: id3v2["TPOS"]?.run { first().parseId3v2PositionField() })
fun ExoPlayerTags.subtitle() = (vorbis["discsubtitle"] ?: id3v2["TSST"])?.first() fun Metadata.subtitle() = (xiph["discsubtitle"] ?: id3v2["TSST"])?.first()
// Dates are somewhat complicated, as not only did their semantics change from a flat year // 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 // value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@ -64,17 +65,17 @@ fun ExoPlayerTags.subtitle() = (vorbis["discsubtitle"] ?: id3v2["TSST"])?.first(
// TODO: Show original and normal dates side-by-side // TODO: Show original and normal dates side-by-side
// TODO: Handle dates that are in "January" because the actual specific release date // TODO: Handle dates that are in "January" because the actual specific release date
// isn't known? // isn't known?
fun ExoPlayerTags.date() = fun Metadata.date() =
(vorbis["originaldate"]?.run { Date.from(first()) } (xiph["originaldate"]?.run { Date.from(first()) }
?: vorbis["date"]?.run { Date.from(first()) } ?: xiph["date"]?.run { Date.from(first()) }
?: vorbis["year"]?.run { Date.from(first()) } ?: xiph["year"]?.run { Date.from(first()) }
?: ?:
// Vorbis dates are less complicated, but there are still several types // xiph dates are less complicated, but there are still several types
// Our hierarchy for dates is as such: // Our hierarchy for dates is as such:
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue // 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
// 2. Date, as it is the most common date type // 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only // 3. Year, as old xiph tags tended to use this (I know this because it's the only
// date tag that android supports, so it must be 15 years old or more!) // date tag that android supports, so it must be 15 years old or more!)
id3v2["TDOR"]?.run { Date.from(first()) } id3v2["TDOR"]?.run { Date.from(first()) }
?: id3v2["TDRC"]?.run { Date.from(first()) } ?: id3v2["TDRC"]?.run { Date.from(first()) }
@ -82,20 +83,20 @@ fun ExoPlayerTags.date() =
?: parseId3v23Date()) ?: parseId3v23Date())
// Album // Album
fun ExoPlayerTags.albumMusicBrainzId() = fun Metadata.albumMusicBrainzId() =
(vorbis["musicbrainz_albumid"] (xiph["musicbrainz_albumid"]
?: vorbis["musicbrainz album id"] ?: xiph["musicbrainz album id"]
?: id3v2["TXXX:musicbrainz album id"] ?: id3v2["TXXX:musicbrainz album id"]
?: id3v2["TXXX:musicbrainz_albumid"]) ?: id3v2["TXXX:musicbrainz_albumid"])
?.first() ?.first()
fun ExoPlayerTags.albumName() = (vorbis["album"] ?: id3v2["TALB"])?.first() fun Metadata.albumName() = (xiph["album"] ?: id3v2["TALB"])?.first()
fun ExoPlayerTags.albumSortName() = (vorbis["albumsort"] ?: id3v2["TSOA"])?.first() fun Metadata.albumSortName() = (xiph["albumsort"] ?: id3v2["TSOA"])?.first()
fun ExoPlayerTags.releaseTypes() = fun Metadata.releaseTypes() =
(vorbis["releasetype"] (xiph["releasetype"]
?: vorbis["musicbrainz album type"] ?: xiph["musicbrainz album type"]
?: id3v2["TXXX:musicbrainz album type"] ?: id3v2["TXXX:musicbrainz album type"]
?: id3v2["TXXX:releasetype"] ?: id3v2["TXXX:releasetype"]
?: ?:
@ -103,25 +104,25 @@ fun ExoPlayerTags.releaseTypes() =
id3v2["GRP1"]) id3v2["GRP1"])
// Artist // Artist
fun ExoPlayerTags.artistMusicBrainzIds() = fun Metadata.artistMusicBrainzIds() =
(vorbis["musicbrainz_artistid"] (xiph["musicbrainz_artistid"]
?: vorbis["musicbrainz artist id"] ?: xiph["musicbrainz artist id"]
?: id3v2["TXXX:musicbrainz artist id"] ?: id3v2["TXXX:musicbrainz artist id"]
?: id3v2["TXXX:musicbrainz_artistid"]) ?: id3v2["TXXX:musicbrainz_artistid"])
fun ExoPlayerTags.artistNames() = fun Metadata.artistNames() =
(vorbis["artists"] (xiph["artists"]
?: vorbis["artist"] ?: xiph["artist"]
?: id3v2["TXXX:artists"] ?: id3v2["TXXX:artists"]
?: id3v2["TPE1"] ?: id3v2["TPE1"]
?: id3v2["TXXX:artist"]) ?: id3v2["TXXX:artist"])
fun ExoPlayerTags.artistSortNames() = fun Metadata.artistSortNames() =
(vorbis["artistssort"] (xiph["artistssort"]
?: vorbis["artists_sort"] ?: xiph["artists_sort"]
?: vorbis["artists sort"] ?: xiph["artists sort"]
?: vorbis["artistsort"] ?: xiph["artistsort"]
?: vorbis["artist sort"] ?: xiph["artist sort"]
?: id3v2["TXXX:artistssort"] ?: id3v2["TXXX:artistssort"]
?: id3v2["TXXX:artists_sort"] ?: id3v2["TXXX:artists_sort"]
?: id3v2["TXXX:artists sort"] ?: id3v2["TXXX:artists sort"]
@ -129,18 +130,18 @@ fun ExoPlayerTags.artistSortNames() =
?: id3v2["artistsort"] ?: id3v2["artistsort"]
?: id3v2["TXXX:artist sort"]) ?: id3v2["TXXX:artist sort"])
fun ExoPlayerTags.albumArtistMusicBrainzIds() = fun Metadata.albumArtistMusicBrainzIds() =
(vorbis["musicbrainz_albumartistid"] (xiph["musicbrainz_albumartistid"]
?: vorbis["musicbrainz album artist id"] ?: xiph["musicbrainz album artist id"]
?: id3v2["TXXX:musicbrainz album artist id"] ?: id3v2["TXXX:musicbrainz album artist id"]
?: id3v2["TXXX:musicbrainz_albumartistid"]) ?: id3v2["TXXX:musicbrainz_albumartistid"])
fun ExoPlayerTags.albumArtistNames() = fun Metadata.albumArtistNames() =
(vorbis["albumartists"] (xiph["albumartists"]
?: vorbis["album_artists"] ?: xiph["album_artists"]
?: vorbis["album artists"] ?: xiph["album artists"]
?: vorbis["albumartist"] ?: xiph["albumartist"]
?: vorbis["album artist"] ?: xiph["album artist"]
?: id3v2["TXXX:albumartists"] ?: id3v2["TXXX:albumartists"]
?: id3v2["TXXX:album_artists"] ?: id3v2["TXXX:album_artists"]
?: id3v2["TXXX:album artists"] ?: id3v2["TXXX:album artists"]
@ -148,12 +149,12 @@ fun ExoPlayerTags.albumArtistNames() =
?: id3v2["TXXX:albumartist"] ?: id3v2["TXXX:albumartist"]
?: id3v2["TXXX:album artist"]) ?: id3v2["TXXX:album artist"])
fun ExoPlayerTags.albumArtistSortNames() = fun Metadata.albumArtistSortNames() =
(vorbis["albumartistssort"] (xiph["albumartistssort"]
?: vorbis["albumartists_sort"] ?: xiph["albumartists_sort"]
?: vorbis["albumartists sort"] ?: xiph["albumartists sort"]
?: vorbis["albumartistsort"] ?: xiph["albumartistsort"]
?: vorbis["album artist sort"] ?: xiph["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"]
@ -163,12 +164,12 @@ fun ExoPlayerTags.albumArtistSortNames() =
?: id3v2["TXXX:album artist sort"]) ?: id3v2["TXXX:album artist sort"])
// Genre // Genre
fun ExoPlayerTags.genreNames() = vorbis["genre"] ?: id3v2["TCON"] fun Metadata.genreNames() = xiph["genre"] ?: id3v2["TCON"]
// Compilation Flag // Compilation Flag
fun ExoPlayerTags.isCompilation() = fun Metadata.isCompilation() =
(vorbis["compilation"] (xiph["compilation"]
?: vorbis["itunescompilation"] ?: xiph["itunescompilation"]
?: id3v2["TCMP"] // This is a non-standard itunes extension ?: id3v2["TCMP"] // This is a non-standard itunes extension
?: id3v2["TXXX:compilation"] ?: id3v2["TXXX:compilation"]
?: id3v2["TXXX:itunescompilation"]) ?: id3v2["TXXX:itunescompilation"])
@ -178,17 +179,17 @@ fun ExoPlayerTags.isCompilation() =
} }
// ReplayGain information // ReplayGain information
fun ExoPlayerTags.replayGainTrackAdjustment() = fun Metadata.replayGainTrackAdjustment() =
(vorbis["r128_track_gain"]?.parseR128Adjustment() (xiph["r128_track_gain"]?.parseR128Adjustment()
?: vorbis["replaygain_track_gain"]?.parseReplayGainAdjustment() ?: xiph["replaygain_track_gain"]?.parseReplayGainAdjustment()
?: id3v2["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()) ?: id3v2["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment())
fun ExoPlayerTags.replayGainAlbumAdjustment() = fun Metadata.replayGainAlbumAdjustment() =
(vorbis["r128_album_gain"]?.parseR128Adjustment() (xiph["r128_album_gain"]?.parseR128Adjustment()
?: vorbis["replaygain_album_gain"]?.parseReplayGainAdjustment() ?: xiph["replaygain_album_gain"]?.parseReplayGainAdjustment()
?: id3v2["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()) ?: id3v2["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment())
private fun ExoPlayerTags.parseId3v23Date(): Date? { private fun Metadata.parseId3v23Date(): Date? {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY // Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present. // is present.
val year = val year =

View file

@ -1,94 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* ExoPlayerTags.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.tag.parse
import androidx.media3.common.Metadata
import androidx.media3.extractor.metadata.id3.InternalFrame
import androidx.media3.extractor.metadata.id3.TextInformationFrame
import androidx.media3.extractor.metadata.vorbis.VorbisComment
import org.oxycblt.musikr.tag.util.correctWhitespace
/**
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags.
*
* @param metadata The [Metadata] to wrap.
* @author Alexander Capehart (OxygenCobalt)
*/
class ExoPlayerTags(metadata: Metadata) {
private val _id3v2 = mutableMapOf<String, MutableList<String>>()
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
val id3v2: Map<String, List<String>>
get() = _id3v2
private val _vorbis = mutableMapOf<String, MutableList<String>>()
/** The vorbis comments found in the file. Can have more than one value. */
val vorbis: Map<String, List<String>>
get() = _vorbis
init {
for (i in 0 until metadata.length()) {
when (val tag = metadata[i]) {
is TextInformationFrame -> {
// Map TXXX frames differently so we can specifically index by their
// descriptions.
val id =
tag.description?.let { "TXXX:${it.sanitize().lowercase()}" }
?: tag.id.sanitize()
val values = tag.values.map { it.sanitize() }.correctWhitespace()
if (values.isNotEmpty()) {
// Normally, duplicate ID3v2 frames are forbidden. But since MP4 atoms,
// which can also have duplicates, are mapped to ID3v2 frames by ExoPlayer,
// we must drop this invariant and gracefully treat duplicates as if they
// are another way of specfiying multi-value tags.
_id3v2.getOrPut(id) { mutableListOf() }.addAll(values)
}
}
is InternalFrame -> {
// Most MP4 metadata atoms map to ID3v2 text frames, except for the ---- atom,
// which has it's own frame. Map this to TXXX, it's rough ID3v2 equivalent.
val id = "TXXX:${tag.description.sanitize().lowercase()}"
val value = tag.text
if (value.isNotEmpty()) {
_id3v2.getOrPut(id) { mutableListOf() }.add(value)
}
}
is VorbisComment -> {
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
val id = tag.key.sanitize().lowercase()
if (id == "metadata_block_picture") {
// Picture, we don't care about these
continue
}
val value = tag.value.sanitize().correctWhitespace()
if (value != null) {
_vorbis.getOrPut(id) { mutableListOf() }.add(value)
}
}
}
}
}
/**
* Copies and sanitizes a possibly invalid string outputted from ExoPlayer.
*
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
* the Unicode replacement byte sequence.
*/
private fun String.sanitize() = String(encodeToByteArray())
}

View file

@ -1,24 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* MediaMetadataTagFields.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.tag.parse
import android.media.MediaMetadataRetriever
fun MediaMetadataRetriever.durationMs() =
extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()

View file

@ -19,48 +19,38 @@
package org.oxycblt.musikr.tag.parse package org.oxycblt.musikr.tag.parse
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.ktaglib.Metadata
import org.oxycblt.musikr.fs.query.DeviceFile import org.oxycblt.musikr.fs.query.DeviceFile
import org.oxycblt.musikr.metadata.AudioMetadata
interface TagParser { interface TagParser {
fun parse(file: DeviceFile, metadata: AudioMetadata): ParsedTags fun parse(file: DeviceFile, metadata: Metadata): ParsedTags
} }
class MissingTagError(what: String) : Error("missing tag: $what") class MissingTagError(what: String) : Error("missing tag: $what")
class TagParserImpl @Inject constructor() : TagParser { class TagParserImpl @Inject constructor() : TagParser {
override fun parse(file: DeviceFile, metadata: AudioMetadata): ParsedTags { override fun parse(file: DeviceFile, metadata: Metadata): ParsedTags {
val exoPlayerMetadata =
metadata.exoPlayerFormat?.metadata
?: return ParsedTags(
durationMs =
metadata.mediaMetadataRetriever.durationMs()
?: throw MissingTagError("durationMs"),
name = file.path.name ?: throw MissingTagError("name"),
)
val exoPlayerTags = ExoPlayerTags(exoPlayerMetadata)
return ParsedTags( return ParsedTags(
durationMs = durationMs = metadata.properties.durationMs,
metadata.mediaMetadataRetriever.durationMs() ?: throw MissingTagError("durationMs"), replayGainTrackAdjustment = metadata.replayGainTrackAdjustment(),
replayGainTrackAdjustment = exoPlayerTags.replayGainTrackAdjustment(), replayGainAlbumAdjustment = metadata.replayGainAlbumAdjustment(),
replayGainAlbumAdjustment = exoPlayerTags.replayGainAlbumAdjustment(), musicBrainzId = metadata.musicBrainzId(),
musicBrainzId = exoPlayerTags.musicBrainzId(), name = metadata.name() ?: file.path.name ?: throw MissingTagError("name"),
name = exoPlayerTags.name() ?: file.path.name ?: throw MissingTagError("name"), sortName = metadata.sortName(),
sortName = exoPlayerTags.sortName(), track = metadata.track(),
track = exoPlayerTags.track(), disc = metadata.disc(),
disc = exoPlayerTags.disc(), subtitle = metadata.subtitle(),
subtitle = exoPlayerTags.subtitle(), date = metadata.date(),
date = exoPlayerTags.date(), albumMusicBrainzId = metadata.albumMusicBrainzId(),
albumMusicBrainzId = exoPlayerTags.albumMusicBrainzId(), albumName = metadata.albumName(),
albumName = exoPlayerTags.albumName(), albumSortName = metadata.albumSortName(),
albumSortName = exoPlayerTags.albumSortName(), releaseTypes = metadata.releaseTypes() ?: listOf(),
releaseTypes = exoPlayerTags.releaseTypes() ?: listOf(), artistMusicBrainzIds = metadata.artistMusicBrainzIds() ?: listOf(),
artistMusicBrainzIds = exoPlayerTags.artistMusicBrainzIds() ?: listOf(), artistNames = metadata.artistNames() ?: listOf(),
artistNames = exoPlayerTags.artistNames() ?: listOf(), artistSortNames = metadata.artistSortNames() ?: listOf(),
artistSortNames = exoPlayerTags.artistSortNames() ?: listOf(), albumArtistMusicBrainzIds = metadata.albumArtistMusicBrainzIds() ?: listOf(),
albumArtistMusicBrainzIds = exoPlayerTags.albumArtistMusicBrainzIds() ?: listOf(), albumArtistNames = metadata.albumArtistNames() ?: listOf(),
albumArtistNames = exoPlayerTags.albumArtistNames() ?: listOf(), albumArtistSortNames = metadata.albumArtistSortNames() ?: listOf(),
albumArtistSortNames = exoPlayerTags.albumArtistSortNames() ?: listOf(), genreNames = metadata.genreNames() ?: listOf())
genreNames = exoPlayerTags.genreNames() ?: listOf())
} }
} }

View file

@ -47,7 +47,7 @@ fun String.parseId3v2PositionField() =
* *
* @see transformPositionField * @see transformPositionField
*/ */
fun parseVorbisPositionField(pos: String?, total: String?) = fun parseXiphPositionField(pos: String?, total: String?) =
transformPositionField(pos?.toIntOrNull(), total?.toIntOrNull()) transformPositionField(pos?.toIntOrNull(), total?.toIntOrNull())
/** /**

View file

@ -18,7 +18,7 @@
<string name="set_key_square_covers" translatable="false">auxio_square_covers</string> <string name="set_key_square_covers" translatable="false">auxio_square_covers</string>
<string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string> <string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string>
<string name="set_key_exclude_non_music" translatable="false">auxio_exclude_non_music</string> <string name="set_key_exclude_non_music" translatable="false">auxio_exclude_non_music</string>
<string name="set_key_music_locations" translatable="false">auxio_music_locations</string> <string name="set_key_music_locations" translatable="false">auxio_music_locations2</string>
<string name="set_key_separators" translatable="false">auxio_separators</string> <string name="set_key_separators" translatable="false">auxio_separators</string>
<string name="set_key_auto_sort_names" translatable="false">auxio_auto_sort_names</string> <string name="set_key_auto_sort_names" translatable="false">auxio_auto_sort_names</string>

View file

@ -1,128 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* TextTagsTest.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.tag.parse
import androidx.media3.common.Metadata
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
import androidx.media3.extractor.metadata.id3.InternalFrame
import androidx.media3.extractor.metadata.id3.TextInformationFrame
import androidx.media3.extractor.metadata.vorbis.VorbisComment
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TextTagsTest {
@Test
fun textTags_vorbis() {
val exoPlayerTags = ExoPlayerTags(VORBIS_METADATA)
assertTrue(exoPlayerTags.id3v2.isEmpty())
assertEquals(listOf("Wheel"), exoPlayerTags.vorbis["title"])
assertEquals(listOf("Paraglow"), exoPlayerTags.vorbis["album"])
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.vorbis["artist"])
assertEquals(listOf("2022"), exoPlayerTags.vorbis["date"])
assertEquals(listOf("ep"), exoPlayerTags.vorbis["releasetype"])
assertEquals(listOf("+2 dB"), exoPlayerTags.vorbis["replaygain_track_gain"])
assertEquals(null, exoPlayerTags.id3v2["APIC"])
}
@Test
fun textTags_id3v2() {
val exoPlayerTags = ExoPlayerTags(ID3V2_METADATA)
assertTrue(exoPlayerTags.vorbis.isEmpty())
assertEquals(listOf("Wheel"), exoPlayerTags.id3v2["TIT2"])
assertEquals(listOf("Paraglow"), exoPlayerTags.id3v2["TALB"])
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.id3v2["TPE1"])
assertEquals(listOf("2022"), exoPlayerTags.id3v2["TDRC"])
assertEquals(listOf("ep"), exoPlayerTags.id3v2["TXXX:musicbrainz album type"])
assertEquals(listOf("+2 dB"), exoPlayerTags.id3v2["TXXX:replaygain_track_gain"])
assertEquals(null, exoPlayerTags.id3v2["metadata_block_picture"])
}
@Test
fun textTags_mp4() {
val exoPlayerTags = ExoPlayerTags(MP4_METADATA)
assertTrue(exoPlayerTags.vorbis.isEmpty())
assertEquals(listOf("Wheel"), exoPlayerTags.id3v2["TIT2"])
assertEquals(listOf("Paraglow"), exoPlayerTags.id3v2["TALB"])
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.id3v2["TPE1"])
assertEquals(listOf("2022"), exoPlayerTags.id3v2["TDRC"])
assertEquals(listOf("ep"), exoPlayerTags.id3v2["TXXX:musicbrainz album type"])
assertEquals(listOf("+2 dB"), exoPlayerTags.id3v2["TXXX:replaygain_track_gain"])
assertEquals(null, exoPlayerTags.id3v2["metadata_block_picture"])
}
@Test
fun textTags_id3v2_vorbis_combined() {
val exoPlayerTags =
ExoPlayerTags(VORBIS_METADATA.copyWithAppendedEntriesFrom(ID3V2_METADATA))
assertEquals(listOf("Wheel"), exoPlayerTags.vorbis["title"])
assertEquals(listOf("Paraglow"), exoPlayerTags.vorbis["album"])
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.vorbis["artist"])
assertEquals(listOf("2022"), exoPlayerTags.vorbis["date"])
assertEquals(listOf("ep"), exoPlayerTags.vorbis["releasetype"])
assertEquals(listOf("+2 dB"), exoPlayerTags.vorbis["replaygain_track_gain"])
assertEquals(null, exoPlayerTags.id3v2["metadata_block_picture"])
assertEquals(listOf("Wheel"), exoPlayerTags.id3v2["TIT2"])
assertEquals(listOf("Paraglow"), exoPlayerTags.id3v2["TALB"])
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.id3v2["TPE1"])
assertEquals(listOf("2022"), exoPlayerTags.id3v2["TDRC"])
assertEquals(null, exoPlayerTags.id3v2["APIC"])
assertEquals(listOf("ep"), exoPlayerTags.id3v2["TXXX:musicbrainz album type"])
assertEquals(listOf("+2 dB"), exoPlayerTags.id3v2["TXXX:replaygain_track_gain"])
}
companion object {
private val VORBIS_METADATA =
Metadata(
VorbisComment("TITLE", "Wheel"),
VorbisComment("ALBUM", "Paraglow"),
VorbisComment("ARTIST", "Parannoul"),
VorbisComment("ARTIST", "Asian Glow"),
VorbisComment("DATE", "2022"),
VorbisComment("RELEASETYPE", "ep"),
VorbisComment("METADATA_BLOCK_PICTURE", ""),
VorbisComment("REPLAYGAIN_TRACK_GAIN", "+2 dB"),
PictureFrame(0, "", "", 0, 0, 0, 0, byteArrayOf()))
private val ID3V2_METADATA =
Metadata(
TextInformationFrame("TIT2", null, listOf("Wheel")),
TextInformationFrame("TALB", null, listOf("Paraglow")),
TextInformationFrame("TPE1", null, listOf("Parannoul", "Asian Glow")),
TextInformationFrame("TDRC", null, listOf("2022")),
TextInformationFrame("TXXX", "MusicBrainz Album Type", listOf("ep")),
TextInformationFrame("TXXX", "replaygain_track_gain", listOf("+2 dB")),
ApicFrame("", "", 0, byteArrayOf()))
// MP4 atoms are mapped to ID3v2 text information frames by ExoPlayer, but can
// duplicate frames and have ---- mapped to InternalFrame.
private val MP4_METADATA =
Metadata(
TextInformationFrame("TIT2", null, listOf("Wheel")),
TextInformationFrame("TALB", null, listOf("Paraglow")),
TextInformationFrame("TPE1", null, listOf("Parannoul")),
TextInformationFrame("TPE1", null, listOf("Asian Glow")),
TextInformationFrame("TDRC", null, listOf("2022")),
TextInformationFrame("TXXX", "MusicBrainz Album Type", listOf("ep")),
InternalFrame("com.apple.iTunes", "replaygain_track_gain", "+2 dB"),
ApicFrame("", "", 0, byteArrayOf()))
}
}