diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt index 870e49f94..9d03f7bfd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt index 1d294e04f..3d102fb61 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor2.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor2.kt new file mode 100644 index 000000000..a442a9265 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor2.kt @@ -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): Flow +} + +class ExoPlayerTagExtractorImpl @Inject constructor( + private val mediaSourceFactory: MediaSource.Factory, + private val tagInterpreter2: TagInterpreter2, +) : ExoPlayerTagExtractor { + override fun process(deviceFiles: Flow) = flow { + val threadPool = ThreadPool(8, Handler(this)) + deviceFiles.collect { file -> + threadPool.enqueue(file) + } + threadPool.empty() + } + + private inner class Handler( + private val collector: FlowCollector + ) : ThreadPool.Handler { + 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(size: Int, private val handler: Handler) { + private val slots = + Array>(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.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( + val thread: HandlerThread, + var task: Task? + ) + + private data class Task( + val input: I, + val future: Future + ) + + interface Handler { + suspend fun produce(thread: HandlerThread, input: I): Future + suspend fun consume(input: I, output: O) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter2.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter2.kt new file mode 100644 index 000000000..5056a5971 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter2.kt @@ -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 . + */ + +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>) { + // 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>): 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>) { + // 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.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.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.-]") } + } +}