music: introduce saf-based tag extractor

This commit is contained in:
Alexander Capehart 2024-11-19 10:16:29 -07:00
parent cadd2d1231
commit 53d0dbd0cb
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 464 additions and 0 deletions

View file

@ -28,5 +28,9 @@ import dagger.hilt.components.SingletonComponent
interface MetadataModule {
@Binds fun tagInterpreter(interpreter: TagInterpreterImpl): TagInterpreter
@Binds fun tagInterpreter2(interpreter: TagInterpreter2Impl): TagInterpreter2
@Binds fun exoPlayerTagExtractor(extractor: ExoPlayerTagExtractorImpl): ExoPlayerTagExtractor
@Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory
}

View file

@ -27,6 +27,7 @@ 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.MetadataRetriever
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.source.MediaPeriod
import androidx.media3.exoplayer.source.MediaSource

View file

@ -0,0 +1,138 @@
package org.oxycblt.auxio.music.metadata
import android.os.HandlerThread
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.DeviceFile
import java.util.concurrent.Future
import javax.inject.Inject
import timber.log.Timber as L
interface TagResult {
class Hit(val rawSong: RawSong) : TagResult
class Miss(val file: DeviceFile) : TagResult
}
interface ExoPlayerTagExtractor {
fun process(deviceFiles: Flow<DeviceFile>): Flow<TagResult>
}
class ExoPlayerTagExtractorImpl @Inject constructor(
private val mediaSourceFactory: MediaSource.Factory,
private val tagInterpreter2: TagInterpreter2,
) : ExoPlayerTagExtractor {
override fun process(deviceFiles: Flow<DeviceFile>) = flow {
val threadPool = ThreadPool(8, Handler(this))
deviceFiles.collect { file ->
threadPool.enqueue(file)
}
threadPool.empty()
}
private inner class Handler(
private val collector: FlowCollector<TagResult>
) : ThreadPool.Handler<DeviceFile, TrackGroupArray> {
override suspend fun produce(thread: HandlerThread, input: DeviceFile) =
MetadataRetriever.retrieveMetadata(
mediaSourceFactory,
MediaItem.fromUri(input.uri),
thread
)
override suspend fun consume(input: DeviceFile, output: TrackGroupArray) {
if (output.isEmpty) {
noMetadata(input)
return
}
val track = output.get(0)
if (track.length == 0) {
noMetadata(input)
return
}
val metadata = track.getFormat(0).metadata
if (metadata == null) {
noMetadata(input)
return
}
val textTags = TextTags(metadata)
val rawSong = RawSong(file = input)
tagInterpreter2.interpretOn(textTags, rawSong)
collector.emit(TagResult.Hit(rawSong))
}
private suspend fun noMetadata(input: DeviceFile) {
L.e("No metadata found for $input")
collector.emit(TagResult.Miss(input))
}
}
}
private class ThreadPool<I, O>(size: Int, private val handler: Handler<I, O>) {
private val slots =
Array<Slot<I, O>>(size) {
Slot(
thread = HandlerThread("Auxio:ThreadPool:$it"),
task = null
)
}
suspend fun enqueue(input: I) {
spin@ while (true) {
for (slot in slots) {
val task = slot.task
if (task == null || task.future.isDone) {
task?.complete()
slot.task = Task(input, handler.produce(slot.thread, input))
break@spin
}
}
}
}
suspend fun empty() {
spin@ while (true) {
val slot = slots.firstOrNull { it.task != null }
if (slot == null) {
break@spin
}
val task = slot.task
if (task != null && task.future.isDone) {
task.complete()
slot.task = null
}
}
}
private suspend fun Task<I, O>.complete() {
try {
// In-practice this should never block, as all clients
// check if the future is done before calling this function.
// If you don't maintain that invariant, this will explode.
@Suppress("BlockingMethodInNonBlockingContext")
handler.consume(input, future.get())
} catch (e: Exception) {
L.e("Failed to complete task for $input, ${e.stackTraceToString()}")
}
}
private data class Slot<I, O>(
val thread: HandlerThread,
var task: Task<I, O>?
)
private data class Task<I, O>(
val input: I,
val future: Future<O>
)
interface Handler<I, O> {
suspend fun produce(thread: HandlerThread, input: I): Future<O>
suspend fun consume(input: I, output: O)
}
}

View file

@ -0,0 +1,321 @@
/*
* Copyright (c) 2023 Auxio Project
* TagInterpreter.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.auxio.music.metadata
import androidx.core.text.isDigitsOnly
import androidx.media3.exoplayer.MetadataRetriever
import javax.inject.Inject
import org.oxycblt.auxio.image.extractor.CoverExtractor
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.util.nonZeroOrNull
/**
* An processing abstraction over the [MetadataRetriever] and [TextTags] workflow that operates on
* [RawSong] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface TagInterpreter2 {
/**
* Poll to see if this worker is done processing.
*
* @return A completed [RawSong] if done, null otherwise.
*/
fun interpretOn(textTags: TextTags, rawSong: RawSong)
}
class TagInterpreter2Impl @Inject constructor(private val coverExtractor: CoverExtractor) :
TagInterpreter2 {
override fun interpretOn(textTags: TextTags, rawSong: RawSong) {
populateWithId3v2(rawSong, textTags.id3v2)
populateWithVorbis(rawSong, textTags.vorbis)
}
private fun populateWithId3v2(rawSong: RawSong, textFrames: Map<String, List<String>>) {
// Song
(textFrames["TXXX:musicbrainz release track id"]
?: textFrames["TXXX:musicbrainz_releasetrackid"])
?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
// Track.
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { rawSong.track = it }
// Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { rawSong.disc = it }
textFrames["TSST"]?.let { rawSong.subtitle = it.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?
(textFrames["TDOR"]?.run { Date.from(first()) }
?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames))
?.let { rawSong.date = it }
// Album
(textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let {
rawSong.albumMusicBrainzId = it.first()
}
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"]
?: textFrames["TXXX:releasetype"]
?:
// This is a non-standard iTunes extension
textFrames["GRP1"])
?.let { rawSong.releaseTypes = it }
// Artist
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
rawSong.artistMusicBrainzIds = it
}
(textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let {
rawSong.artistNames = it
}
(textFrames["TXXX:artistssort"]
?: textFrames["TXXX:artists_sort"]
?: textFrames["TXXX:artists sort"]
?: textFrames["TSOP"]
?: textFrames["artistsort"]
?: textFrames["TXXX:artist sort"])
?.let { rawSong.artistSortNames = it }
// Album artist
(textFrames["TXXX:musicbrainz album artist id"]
?: textFrames["TXXX:musicbrainz_albumartistid"])
?.let { rawSong.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"]
?: textFrames["TXXX:album_artists"]
?: textFrames["TXXX:album artists"]
?: textFrames["TPE2"]
?: textFrames["TXXX:albumartist"]
?: textFrames["TXXX:album artist"])
?.let { rawSong.albumArtistNames = it }
(textFrames["TXXX:albumartistssort"]
?: textFrames["TXXX:albumartists_sort"]
?: textFrames["TXXX:albumartists sort"]
?: textFrames["TXXX:albumartistsort"]
// This is a non-standard iTunes extension
?: textFrames["TSO2"]
?: textFrames["TXXX:album artist sort"])
?.let { rawSong.albumArtistSortNames = it }
// Genre
textFrames["TCON"]?.let { rawSong.genreNames = it }
// Compilation Flag
(textFrames["TCMP"] // This is a non-standard itunes extension
?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"])
?.let {
// Ignore invalid instances of this tag
if (it.size != 1 || it[0] != "1") return@let
// Change the metadata to be a compilation album made by "Various Artists"
rawSong.albumArtistNames =
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
}
// ReplayGain information
textFrames["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()?.let {
rawSong.replayGainTrackAdjustment = it
}
textFrames["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()?.let {
rawSong.replayGainAlbumAdjustment = it
}
}
private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present.
val year =
textFrames["TORY"]?.run { first().toIntOrNull() }
?: textFrames["TYER"]?.run { first().toIntOrNull() }
?: return null
val tdat = textFrames["TDAT"]
return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) {
// TDAT frames consist of a 4-digit string where the first two digits are
// the month and the last two digits are the day.
val mm = tdat.first().substring(0..1).toInt()
val dd = tdat.first().substring(2..3).toInt()
val time = textFrames["TIME"]
if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) {
// TIME frames consist of a 4-digit string where the first two digits are
// the hour and the last two digits are the minutes. No second value is
// possible.
val hh = time.first().substring(0..1).toInt()
val mi = time.first().substring(2..3).toInt()
// Able to return a full date.
Date.from(year, mm, dd, hh, mi)
} else {
// Unable to parse time, just return a date
Date.from(year, mm, dd)
}
} else {
// Unable to parse month/day, just return a year
return Date.from(year)
}
}
private fun populateWithVorbis(rawSong: RawSong, comments: Map<String, List<String>>) {
// Song
(comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let {
rawSong.musicBrainzId = it.first()
}
comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { rawSong.sortName = it.first() }
// Track.
parseVorbisPositionField(
comments["tracknumber"]?.first(),
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()
)
?.let { rawSong.track = it }
// Disc and it's subtitle name.
parseVorbisPositionField(
comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()
)
?.let { rawSong.disc = it }
comments["discsubtitle"]?.let { rawSong.subtitle = it.first() }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
// 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
// date tag that android supports, so it must be 15 years old or more!)
(comments["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { Date.from(first()) })
?.let { rawSong.date = it }
// Album
(comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let {
rawSong.albumMusicBrainzId = it.first()
}
comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
(comments["releasetype"] ?: comments["musicbrainz album type"])?.let {
rawSong.releaseTypes = it
}
// Artist
(comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let {
rawSong.artistMusicBrainzIds = it
}
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artistssort"]
?: comments["artists_sort"]
?: comments["artists sort"]
?: comments["artistsort"]
?: comments["artist sort"])
?.let { rawSong.artistSortNames = it }
// Album artist
(comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
rawSong.albumArtistMusicBrainzIds = it
}
(comments["albumartists"]
?: comments["album_artists"]
?: comments["album artists"]
?: comments["albumartist"]
?: comments["album artist"])
?.let { rawSong.albumArtistNames = it }
(comments["albumartistssort"]
?: comments["albumartists_sort"]
?: comments["albumartists sort"]
?: comments["albumartistsort"]
?: comments["album artist sort"])
?.let { rawSong.albumArtistSortNames = it }
// Genre
comments["genre"]?.let { rawSong.genreNames = it }
// Compilation Flag
(comments["compilation"] ?: comments["itunescompilation"])?.let {
// Ignore invalid instances of this tag
if (it.size != 1 || it[0] != "1") return@let
// Change the metadata to be a compilation album made by "Various Artists"
rawSong.albumArtistNames =
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
}
// ReplayGain information
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
// replaygain_*_gain tag, but opus has it's own "r128_*_gain" ReplayGain specification,
// which requires dividing the adjustment by 256 to get the gain. This is used alongside
// the base adjustment intrinsic to the format to create the normalized adjustment. This is
// normally the only tag used for opus files, but some software still writes replay gain
// tags anyway.
(comments["r128_track_gain"]?.parseR128Adjustment()
?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment())
?.let { rawSong.replayGainTrackAdjustment = it }
(comments["r128_album_gain"]?.parseR128Adjustment()
?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment())
?.let { rawSong.replayGainAlbumAdjustment = it }
}
private fun List<String>.parseR128Adjustment() =
first()
.replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "")
.toFloatOrNull()
?.nonZeroOrNull()
?.run {
// Convert to fixed-point and adjust to LUFS 18 to match the ReplayGain scale
this / 256f + 5
}
/**
* Parse a ReplayGain adjustment into a float value.
*
* @return A parsed adjustment float, or null if the adjustment had invalid formatting.
*/
private fun List<String>.parseReplayGainAdjustment() =
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull()
private companion object {
const val COVER_KEY_SAMPLE = 32
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
val COMPILATION_RELEASE_TYPES = listOf("compilation")
/**
* Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
* https://github.com/vanilla-music/vanilla
*/
val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") }
}
}