music: refactor tag extraction

- Include MediaMetadataRetriever use
- Separate interpretation into extension functions
- AudioFile is now immutable
- Removed any type of progressive AudioFile preparation
(like in the old loader)
This commit is contained in:
Alexander Capehart 2024-11-25 12:55:17 -07:00
parent 73ff7e2c7f
commit d633a6b9f1
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 429 additions and 555 deletions

View file

@ -3,21 +3,25 @@ package org.oxycblt.auxio.music.stack.explore
import android.net.Uri import android.net.Uri
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.withIndex
import org.oxycblt.auxio.music.stack.explore.cache.TagCache import org.oxycblt.auxio.music.stack.explore.cache.TagCache
import org.oxycblt.auxio.music.stack.explore.extractor.ExoPlayerTagExtractor import org.oxycblt.auxio.music.stack.explore.extractor.TagExtractor
import org.oxycblt.auxio.music.stack.explore.extractor.TagResult import org.oxycblt.auxio.music.stack.explore.cache.CacheResult
import org.oxycblt.auxio.music.stack.explore.fs.DeviceFiles import org.oxycblt.auxio.music.stack.explore.fs.DeviceFiles
import org.oxycblt.auxio.music.stack.explore.playlists.StoredPlaylists import org.oxycblt.auxio.music.stack.explore.playlists.StoredPlaylists
import javax.inject.Inject import javax.inject.Inject
@ -34,23 +38,35 @@ data class Files(
class ExplorerImpl @Inject constructor( class ExplorerImpl @Inject constructor(
private val deviceFiles: DeviceFiles, private val deviceFiles: DeviceFiles,
private val tagCache: TagCache, private val tagCache: TagCache,
private val tagExtractor: ExoPlayerTagExtractor, private val tagExtractor: TagExtractor,
private val storedPlaylists: StoredPlaylists private val storedPlaylists: StoredPlaylists
) : Explorer { ) : Explorer {
@OptIn(ExperimentalCoroutinesApi::class)
override fun explore(uris: List<Uri>): Files { override fun explore(uris: List<Uri>): Files {
val deviceFiles = deviceFiles.explore(uris.asFlow()).flowOn(Dispatchers.IO).buffer() val deviceFiles = deviceFiles.explore(uris.asFlow()).flowOn(Dispatchers.IO).buffer()
val tagRead = tagCache.read(deviceFiles).flowOn(Dispatchers.IO).buffer() val tagRead = tagCache.read(deviceFiles).flowOn(Dispatchers.IO).buffer()
val (cacheFiles, cacheSongs) = tagRead.results() val (uncachedDeviceFiles, cachedAudioFiles) = tagRead.results()
val tagExtractor = tagExtractor.process(cacheFiles).flowOn(Dispatchers.IO).buffer() val extractedAudioFiles = uncachedDeviceFiles.split(8).map {
val (_, extractorSongs) = tagExtractor.results() tagExtractor.extract(it).flowOn(Dispatchers.IO).buffer()
val writtenExtractorSongs = tagCache.write(extractorSongs).flowOn(Dispatchers.IO).buffer() }.asFlow().flattenMerge()
val writtenAudioFiles = tagCache.write(extractedAudioFiles).flowOn(Dispatchers.IO).buffer()
val playlistFiles = storedPlaylists.read() val playlistFiles = storedPlaylists.read()
return Files(merge(cacheSongs, writtenExtractorSongs), playlistFiles) return Files(merge(cachedAudioFiles, writtenAudioFiles), playlistFiles)
} }
private fun Flow<TagResult>.results(): Pair<Flow<DeviceFile>, Flow<AudioFile>> { private fun Flow<CacheResult>.results(): Pair<Flow<DeviceFile>, Flow<AudioFile>> {
val files = filterIsInstance<TagResult.Miss>().map { it.file } val shared = shareIn(CoroutineScope(Dispatchers.Main), SharingStarted.WhileSubscribed(), replay = 0)
val songs = filterIsInstance<TagResult.Hit>().map { it.audioFile } val files = shared.filterIsInstance<CacheResult.Miss>().map { it.deviceFile }
val songs = shared.filterIsInstance<CacheResult.Hit>().map { it.audioFile }
return files to songs return files to songs
} }
private fun <T> Flow<T>.split(n: Int): Array<Flow<T>> {
val indexed = withIndex()
val shared = indexed.shareIn(CoroutineScope(Dispatchers.Main), SharingStarted.WhileSubscribed(), replay = 0)
return Array(n) {
shared.filter { it.index % n == 0 }
.map { it.value }
}
}
} }

View file

@ -40,27 +40,27 @@ data class DeviceFile(
*/ */
data class AudioFile( data class AudioFile(
val deviceFile: DeviceFile, val deviceFile: DeviceFile,
var durationMs: Long? = null, val durationMs: Long,
var replayGainTrackAdjustment: Float? = null, val replayGainTrackAdjustment: Float? = null,
var replayGainAlbumAdjustment: Float? = null, val replayGainAlbumAdjustment: Float? = null,
var musicBrainzId: String? = null, val musicBrainzId: String? = null,
var name: String? = null, val name: String,
var sortName: String? = null, val sortName: String? = null,
var track: Int? = null, val track: Int? = null,
var disc: Int? = null, val disc: Int? = null,
var subtitle: String? = null, val subtitle: String? = null,
var date: Date? = null, val date: Date? = null,
var albumMusicBrainzId: String? = null, val albumMusicBrainzId: String? = null,
var albumName: String? = null, val albumName: String? = null,
var albumSortName: String? = null, val albumSortName: String? = null,
var releaseTypes: List<String> = listOf(), val releaseTypes: List<String> = listOf(),
var artistMusicBrainzIds: List<String> = listOf(), val artistMusicBrainzIds: List<String> = listOf(),
var artistNames: List<String> = listOf(), val artistNames: List<String> = listOf(),
var artistSortNames: List<String> = listOf(), val artistSortNames: List<String> = listOf(),
var albumArtistMusicBrainzIds: List<String> = listOf(), val albumArtistMusicBrainzIds: List<String> = listOf(),
var albumArtistNames: List<String> = listOf(), val albumArtistNames: List<String> = listOf(),
var albumArtistSortNames: List<String> = listOf(), val albumArtistSortNames: List<String> = listOf(),
var genreNames: List<String> = listOf() val genreNames: List<String> = listOf()
) )
data class PlaylistFile( data class PlaylistFile(

View file

@ -23,28 +23,29 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import org.oxycblt.auxio.music.stack.explore.AudioFile import org.oxycblt.auxio.music.stack.explore.AudioFile
import org.oxycblt.auxio.music.stack.explore.extractor.TagResult
import org.oxycblt.auxio.music.stack.explore.DeviceFile import org.oxycblt.auxio.music.stack.explore.DeviceFile
sealed interface CacheResult {
data class Hit(val audioFile: AudioFile) : CacheResult
data class Miss(val deviceFile: DeviceFile) : CacheResult
}
interface TagCache { interface TagCache {
fun read(files: Flow<DeviceFile>): Flow<TagResult> fun read(files: Flow<DeviceFile>): Flow<CacheResult>
fun write(rawSongs: Flow<AudioFile>): Flow<AudioFile> fun write(rawSongs: Flow<AudioFile>): Flow<AudioFile>
} }
class TagCacheImpl @Inject constructor(private val tagDao: TagDao) : TagCache { class TagCacheImpl @Inject constructor(private val tagDao: TagDao) : TagCache {
override fun read(files: Flow<DeviceFile>) = override fun read(files: Flow<DeviceFile>) =
files.transform<DeviceFile, TagResult> { file -> files.transform<DeviceFile, CacheResult> { file ->
val tags = tagDao.selectTags(file.uri.toString(), file.lastModified) val tags = tagDao.selectTags(file.uri.toString(), file.lastModified)
if (tags != null) { if (tags != null) {
val audioFile = AudioFile(deviceFile = file) CacheResult.Hit(tags.toAudioFile(file))
tags.copyToRaw(audioFile)
TagResult.Hit(audioFile)
} else { } else {
TagResult.Miss(file) CacheResult.Miss(file)
} }
} }
override fun write(rawSongs: Flow<AudioFile>) = override fun write(rawSongs: Flow<AudioFile>) =
rawSongs.onEach { rawSong -> tagDao.updateTags(Tags.fromRaw(rawSong)) } rawSongs.onEach { file -> tagDao.updateTags(Tags.fromAudioFile(file)) }
} }

View file

@ -30,6 +30,7 @@ import androidx.room.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import org.oxycblt.auxio.music.stack.explore.AudioFile import org.oxycblt.auxio.music.stack.explore.AudioFile
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.stack.explore.DeviceFile
import org.oxycblt.auxio.music.stack.explore.extractor.correctWhitespace import org.oxycblt.auxio.music.stack.explore.extractor.correctWhitespace
import org.oxycblt.auxio.music.stack.explore.extractor.splitEscaped import org.oxycblt.auxio.music.stack.explore.extractor.splitEscaped
@ -59,76 +60,70 @@ data class Tags(
/** @see AudioFile */ /** @see AudioFile */
val durationMs: Long, val durationMs: Long,
/** @see AudioFile.replayGainTrackAdjustment */ /** @see AudioFile.replayGainTrackAdjustment */
val replayGainTrackAdjustment: Float? = null, val replayGainTrackAdjustment: Float?,
/** @see AudioFile.replayGainAlbumAdjustment */ /** @see AudioFile.replayGainAlbumAdjustment */
val replayGainAlbumAdjustment: Float? = null, val replayGainAlbumAdjustment: Float?,
/** @see AudioFile.musicBrainzId */ /** @see AudioFile.musicBrainzId */
var musicBrainzId: String? = null, val musicBrainzId: String?,
/** @see AudioFile.name */ /** @see AudioFile.name */
var name: String, val name: String,
/** @see AudioFile.sortName */ /** @see AudioFile.sortName */
var sortName: String? = null, val sortName: String?,
/** @see AudioFile.track */ /** @see AudioFile.track */
var track: Int? = null, val track: Int?,
/** @see AudioFile.name */ /** @see AudioFile.name */
var disc: Int? = null, val disc: Int?,
/** @See AudioFile.subtitle */ /** @See AudioFile.subtitle */
var subtitle: String? = null, val subtitle: String?,
/** @see AudioFile.date */ /** @see AudioFile.date */
var date: Date? = null, val date: Date?,
/** @see AudioFile.albumMusicBrainzId */ /** @see AudioFile.albumMusicBrainzId */
var albumMusicBrainzId: String? = null, val albumMusicBrainzId: String?,
/** @see AudioFile.albumName */ /** @see AudioFile.albumName */
var albumName: String, val albumName: String?,
/** @see AudioFile.albumSortName */ /** @see AudioFile.albumSortName */
var albumSortName: String? = null, val albumSortName: String?,
/** @see AudioFile.releaseTypes */ /** @see AudioFile.releaseTypes */
var releaseTypes: List<String> = listOf(), val releaseTypes: List<String> = listOf(),
/** @see AudioFile.artistMusicBrainzIds */ /** @see AudioFile.artistMusicBrainzIds */
var artistMusicBrainzIds: List<String> = listOf(), val artistMusicBrainzIds: List<String> = listOf(),
/** @see AudioFile.artistNames */ /** @see AudioFile.artistNames */
var artistNames: List<String> = listOf(), val artistNames: List<String> = listOf(),
/** @see AudioFile.artistSortNames */ /** @see AudioFile.artistSortNames */
var artistSortNames: List<String> = listOf(), val artistSortNames: List<String> = listOf(),
/** @see AudioFile.albumArtistMusicBrainzIds */ /** @see AudioFile.albumArtistMusicBrainzIds */
var albumArtistMusicBrainzIds: List<String> = listOf(), val albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see AudioFile.albumArtistNames */ /** @see AudioFile.albumArtistNames */
var albumArtistNames: List<String> = listOf(), val albumArtistNames: List<String> = listOf(),
/** @see AudioFile.albumArtistSortNames */ /** @see AudioFile.albumArtistSortNames */
var albumArtistSortNames: List<String> = listOf(), val albumArtistSortNames: List<String> = listOf(),
/** @see AudioFile.genreNames */ /** @see AudioFile.genreNames */
var genreNames: List<String> = listOf() val genreNames: List<String> = listOf()
) { ) {
fun copyToRaw(audioFile: AudioFile) { fun toAudioFile(deviceFile: DeviceFile) =
audioFile.musicBrainzId = musicBrainzId AudioFile(
audioFile.name = name deviceFile = deviceFile,
audioFile.sortName = sortName musicBrainzId = musicBrainzId,
name = name,
audioFile.durationMs = durationMs sortName = sortName,
durationMs = durationMs,
audioFile.replayGainTrackAdjustment = replayGainTrackAdjustment replayGainTrackAdjustment = replayGainTrackAdjustment,
audioFile.replayGainAlbumAdjustment = replayGainAlbumAdjustment replayGainAlbumAdjustment = replayGainAlbumAdjustment,
track = track,
audioFile.track = track disc = disc,
audioFile.disc = disc subtitle = subtitle,
audioFile.subtitle = subtitle date = date,
audioFile.date = date albumMusicBrainzId = albumMusicBrainzId,
albumName = albumName,
audioFile.albumMusicBrainzId = albumMusicBrainzId albumSortName = albumSortName,
audioFile.albumName = albumName releaseTypes = releaseTypes,
audioFile.albumSortName = albumSortName artistMusicBrainzIds = artistMusicBrainzIds,
audioFile.releaseTypes = releaseTypes artistNames = artistNames,
artistSortNames = artistSortNames,
audioFile.artistMusicBrainzIds = artistMusicBrainzIds albumArtistMusicBrainzIds = albumArtistMusicBrainzIds,
audioFile.artistNames = artistNames albumArtistNames = albumArtistNames,
audioFile.artistSortNames = artistSortNames albumArtistSortNames = albumArtistSortNames,
genreNames = genreNames)
audioFile.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
audioFile.albumArtistNames = albumArtistNames
audioFile.albumArtistSortNames = albumArtistSortNames
audioFile.genreNames = genreNames
}
object Converters { object Converters {
@TypeConverter @TypeConverter
@ -144,14 +139,14 @@ data class Tags(
} }
companion object { companion object {
fun fromRaw(audioFile: AudioFile) = fun fromAudioFile(audioFile: AudioFile) =
Tags( Tags(
uri = audioFile.deviceFile.uri.toString(), uri = audioFile.deviceFile.uri.toString(),
dateModified = audioFile.deviceFile.lastModified, dateModified = audioFile.deviceFile.lastModified,
musicBrainzId = audioFile.musicBrainzId, musicBrainzId = audioFile.musicBrainzId,
name = requireNotNull(audioFile.name) { "Invalid raw: No name" }, name = audioFile.name,
sortName = audioFile.sortName, sortName = audioFile.sortName,
durationMs = requireNotNull(audioFile.durationMs) { "Invalid raw: No duration" }, durationMs = audioFile.durationMs,
replayGainTrackAdjustment = audioFile.replayGainTrackAdjustment, replayGainTrackAdjustment = audioFile.replayGainTrackAdjustment,
replayGainAlbumAdjustment = audioFile.replayGainAlbumAdjustment, replayGainAlbumAdjustment = audioFile.replayGainAlbumAdjustment,
track = audioFile.track, track = audioFile.track,
@ -159,7 +154,7 @@ data class Tags(
subtitle = audioFile.subtitle, subtitle = audioFile.subtitle,
date = audioFile.date, date = audioFile.date,
albumMusicBrainzId = audioFile.albumMusicBrainzId, albumMusicBrainzId = audioFile.albumMusicBrainzId,
albumName = requireNotNull(audioFile.albumName) { "Invalid raw: No album name" }, albumName = audioFile.albumName,
albumSortName = audioFile.albumSortName, albumSortName = audioFile.albumSortName,
releaseTypes = audioFile.releaseTypes, releaseTypes = audioFile.releaseTypes,
artistMusicBrainzIds = audioFile.artistMusicBrainzIds, artistMusicBrainzIds = audioFile.artistMusicBrainzIds,

View file

@ -1,144 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* ExoPlayerTagExtractor.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.stack.explore.extractor
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 java.util.concurrent.Future
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow
import org.oxycblt.auxio.music.stack.explore.AudioFile
import org.oxycblt.auxio.music.stack.explore.DeviceFile
import timber.log.Timber as L
interface TagResult {
class Hit(val audioFile: AudioFile) : 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: TagInterpreter,
) : 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 audioFile = AudioFile(deviceFile = input)
tagInterpreter2.interpretOn(textTags, audioFile)
collector.emit(TagResult.Hit(audioFile))
}
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,113 @@
/*
* Copyright (c) 2023 Auxio Project
* TagExtractor.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.stack.explore.extractor
import android.content.Context
import android.media.MediaMetadataRetriever
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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.guava.asDeferred
import javax.inject.Inject
import org.oxycblt.auxio.music.stack.explore.AudioFile
import org.oxycblt.auxio.music.stack.explore.DeviceFile
interface TagExtractor {
fun extract(
deviceFiles: Flow<DeviceFile>
): Flow<AudioFile>
}
class TagExtractorImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val mediaSourceFactory: MediaSource.Factory,
) : TagExtractor {
override fun extract(
deviceFiles: Flow<DeviceFile>
) = flow {
val thread = HandlerThread("TagExtractor:${hashCode()}")
deviceFiles.collect { deviceFile ->
val exoPlayerMetadataFuture =
MetadataRetriever.retrieveMetadata(
mediaSourceFactory, MediaItem.fromUri(deviceFile.uri), thread
)
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(context, deviceFile.uri)
val exoPlayerMetadata = exoPlayerMetadataFuture.asDeferred().await()
val result = extractTags(deviceFile, exoPlayerMetadata, mediaMetadataRetriever)
mediaMetadataRetriever.close()
emit(result)
}
}
private fun extractTags(
input: DeviceFile,
output: TrackGroupArray,
retriever: MediaMetadataRetriever
): AudioFile {
if (output.isEmpty) return defaultAudioFile(input, retriever)
val track = output.get(0)
if (track.length == 0) return defaultAudioFile(input, retriever)
val format = track.getFormat(0)
val metadata = format.metadata ?: return defaultAudioFile(input, retriever)
val textTags = TextTags(metadata)
return AudioFile(
deviceFile = input,
durationMs = need(retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_DURATION
)?.toLong(), "duration"),
replayGainTrackAdjustment = textTags.replayGainTrackAdjustment(),
replayGainAlbumAdjustment = textTags.replayGainAlbumAdjustment(),
musicBrainzId = textTags.musicBrainzId(),
name = need(textTags.name() ?: input.path.name, "name"),
sortName = textTags.sortName(),
track = textTags.track(),
disc = textTags.disc(),
subtitle = textTags.subtitle(),
date = textTags.date(),
albumMusicBrainzId = textTags.albumMusicBrainzId(),
albumName = textTags.albumName(),
albumSortName = textTags.albumSortName(),
releaseTypes = textTags.releaseTypes() ?: listOf(),
artistMusicBrainzIds = textTags.artistMusicBrainzIds() ?: listOf(),
artistNames = textTags.artistNames() ?: listOf(),
artistSortNames = textTags.artistSortNames() ?: listOf(),
albumArtistMusicBrainzIds = textTags.albumArtistMusicBrainzIds() ?: listOf(),
albumArtistNames = textTags.albumArtistNames() ?: listOf(),
albumArtistSortNames = textTags.albumArtistSortNames() ?: listOf(),
genreNames = textTags.genreNames() ?: listOf()
)
}
private fun defaultAudioFile(deviceFile: DeviceFile, metadataRetriever: MediaMetadataRetriever) =
AudioFile(
deviceFile,
name = need(deviceFile.path.name, "name"),
durationMs = need(metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong(), "duration"),
)
private fun <T> need(a: T, called: String) =
requireNotNull(a) { "Invalid tag, missing $called" }
}

View file

@ -0,0 +1,210 @@
package org.oxycblt.auxio.music.stack.explore.extractor
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.util.nonZeroOrNull
// Song
fun TextTags.musicBrainzId() =
(vorbis["musicbrainz_releasetrackid"] ?: vorbis["musicbrainz release track id"]
?: id3v2["TXXX:musicbrainz release track id"]
?: id3v2["TXXX:musicbrainz_releasetrackid"])?.first()
fun TextTags.name() = (vorbis["title"] ?: id3v2["TIT2"])?.first()
fun TextTags.sortName() = (vorbis["titlesort"] ?: id3v2["TSOT"])?.first()
// Track.
fun TextTags.track() = (parseVorbisPositionField(
vorbis["tracknumber"]?.first(),
(vorbis["totaltracks"] ?: vorbis["tracktotal"] ?: vorbis["trackc"])?.first()
)
?: id3v2["TRCK"]?.run { first().parseId3v2PositionField() })
// Disc and it's subtitle name.
fun TextTags.disc() = (parseVorbisPositionField(
vorbis["discnumber"]?.first(),
(vorbis["totaldiscs"] ?: vorbis["disctotal"] ?: vorbis["discc"])?.run { first() })
?: id3v2["TPOS"]?.run { first().parseId3v2PositionField() })
fun TextTags.subtitle() =
(vorbis["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?
fun TextTags.date() = (vorbis["originaldate"]?.run { Date.from(first()) }
?: vorbis["date"]?.run { Date.from(first()) }
?: vorbis["year"]?.run { Date.from(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!)
id3v2["TDOR"]?.run { Date.from(first()) }
?: id3v2["TDRC"]?.run { Date.from(first()) }
?: id3v2["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date())
// Album
fun TextTags.albumMusicBrainzId() =
(vorbis["musicbrainz_albumid"] ?: vorbis["musicbrainz album id"]
?: id3v2["TXXX:musicbrainz album id"] ?: id3v2["TXXX:musicbrainz_albumid"])?.first()
fun TextTags.albumName() = (vorbis["album"] ?: id3v2["TALB"])?.first()
fun TextTags.albumSortName() = (vorbis["albumsort"] ?: id3v2["TSOA"])?.first()
fun TextTags.releaseTypes() = (
vorbis["releasetype"] ?: vorbis["musicbrainz album type"]
?: id3v2["TXXX:musicbrainz album type"]
?: id3v2["TXXX:releasetype"]
?:
// This is a non-standard iTunes extension
id3v2["GRP1"]
)
// Artist
fun TextTags.artistMusicBrainzIds() =
(vorbis["musicbrainz_artistid"] ?: vorbis["musicbrainz artist id"]
?: id3v2["TXXX:musicbrainz artist id"] ?: id3v2["TXXX:musicbrainz_artistid"])
fun TextTags.artistNames() = (vorbis["artists"] ?: vorbis["artist"] ?: id3v2["TXXX:artists"]
?: id3v2["TPE1"] ?: id3v2["TXXX:artist"])
fun TextTags.artistSortNames() = (vorbis["artistssort"]
?: vorbis["artists_sort"]
?: vorbis["artists sort"]
?: vorbis["artistsort"]
?: vorbis["artist sort"] ?: id3v2["TXXX:artistssort"]
?: id3v2["TXXX:artists_sort"]
?: id3v2["TXXX:artists sort"]
?: id3v2["TSOP"]
?: id3v2["artistsort"]
?: id3v2["TXXX:artist sort"]
)
fun TextTags.albumArtistMusicBrainzIds() = (
vorbis["musicbrainz_albumartistid"] ?: vorbis["musicbrainz album artist id"]
?: id3v2["TXXX:musicbrainz album artist id"]
?: id3v2["TXXX:musicbrainz_albumartistid"]
)
fun TextTags.albumArtistNames() = (
vorbis["albumartists"]
?: vorbis["album_artists"]
?: vorbis["album artists"]
?: vorbis["albumartist"]
?: vorbis["album artist"]
?: id3v2["TXXX:albumartists"]
?: id3v2["TXXX:album_artists"]
?: id3v2["TXXX:album artists"]
?: id3v2["TPE2"]
?: id3v2["TXXX:albumartist"]
?: id3v2["TXXX:album artist"]
)
fun TextTags.albumArtistSortNames() = (vorbis["albumartistssort"]
?: vorbis["albumartists_sort"]
?: vorbis["albumartists sort"]
?: vorbis["albumartistsort"]
?: vorbis["album artist sort"] ?: id3v2["TXXX:albumartistssort"]
?: id3v2["TXXX:albumartists_sort"]
?: id3v2["TXXX:albumartists sort"]
?: id3v2["TXXX:albumartistsort"]
// This is a non-standard iTunes extension
?: id3v2["TSO2"]
?: id3v2["TXXX:album artist sort"]
)
// Genre
fun TextTags.genreNames() = vorbis["genre"] ?: id3v2["TCON"]
// Compilation Flag
fun TextTags.isCompilation() = (vorbis["compilation"] ?: vorbis["itunescompilation"]
?: id3v2["TCMP"] // This is a non-standard itunes extension
?: id3v2["TXXX:compilation"] ?: id3v2["TXXX:itunescompilation"]
)
?.let {
// Ignore invalid instances of this tag
it == listOf("1")
}
// ReplayGain information
fun TextTags.replayGainTrackAdjustment() = (vorbis["r128_track_gain"]?.parseR128Adjustment()
?: vorbis["replaygain_track_gain"]?.parseReplayGainAdjustment()
?: id3v2["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment())
fun TextTags.replayGainAlbumAdjustment() = (vorbis["r128_album_gain"]?.parseR128Adjustment()
?: vorbis["replaygain_album_gain"]?.parseReplayGainAdjustment()
?: id3v2["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment())
private fun TextTags.parseId3v23Date(): Date? {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present.
val year =
id3v2["TORY"]?.run { first().toIntOrNull() }
?: id3v2["TYER"]?.run { first().toIntOrNull() }
?: return null
val tdat = id3v2["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 = id3v2["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 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()
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.-]") }

View file

@ -1,317 +0,0 @@
/*
* 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.stack.explore.extractor
import androidx.core.text.isDigitsOnly
import androidx.media3.exoplayer.MetadataRetriever
import javax.inject.Inject
import org.oxycblt.auxio.music.stack.explore.AudioFile
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
* [AudioFile] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface TagInterpreter {
/**
* Poll to see if this worker is done processing.
*
* @return A completed [AudioFile] if done, null otherwise.
*/
fun interpretOn(textTags: TextTags, audioFile: AudioFile)
}
class TagInterpreterImpl @Inject constructor() : TagInterpreter {
override fun interpretOn(textTags: TextTags, audioFile: AudioFile) {
populateWithId3v2(audioFile, textTags.id3v2)
populateWithVorbis(audioFile, textTags.vorbis)
}
private fun populateWithId3v2(audioFile: AudioFile, textFrames: Map<String, List<String>>) {
// Song
(textFrames["TXXX:musicbrainz release track id"]
?: textFrames["TXXX:musicbrainz_releasetrackid"])
?.let { audioFile.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { audioFile.name = it.first() }
textFrames["TSOT"]?.let { audioFile.sortName = it.first() }
// Track.
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { audioFile.track = it }
// Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { audioFile.disc = it }
textFrames["TSST"]?.let { audioFile.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 { audioFile.date = it }
// Album
(textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let {
audioFile.albumMusicBrainzId = it.first()
}
textFrames["TALB"]?.let { audioFile.albumName = it.first() }
textFrames["TSOA"]?.let { audioFile.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"]
?: textFrames["TXXX:releasetype"]
?:
// This is a non-standard iTunes extension
textFrames["GRP1"])
?.let { audioFile.releaseTypes = it }
// Artist
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
audioFile.artistMusicBrainzIds = it
}
(textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let {
audioFile.artistNames = it
}
(textFrames["TXXX:artistssort"]
?: textFrames["TXXX:artists_sort"]
?: textFrames["TXXX:artists sort"]
?: textFrames["TSOP"]
?: textFrames["artistsort"]
?: textFrames["TXXX:artist sort"])
?.let { audioFile.artistSortNames = it }
// Album artist
(textFrames["TXXX:musicbrainz album artist id"]
?: textFrames["TXXX:musicbrainz_albumartistid"])
?.let { audioFile.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"]
?: textFrames["TXXX:album_artists"]
?: textFrames["TXXX:album artists"]
?: textFrames["TPE2"]
?: textFrames["TXXX:albumartist"]
?: textFrames["TXXX:album artist"])
?.let { audioFile.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 { audioFile.albumArtistSortNames = it }
// Genre
textFrames["TCON"]?.let { audioFile.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"
audioFile.albumArtistNames =
audioFile.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
audioFile.releaseTypes = audioFile.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
}
// ReplayGain information
textFrames["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()?.let {
audioFile.replayGainTrackAdjustment = it
}
textFrames["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()?.let {
audioFile.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(audioFile: AudioFile, comments: Map<String, List<String>>) {
// Song
(comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let {
audioFile.musicBrainzId = it.first()
}
comments["title"]?.let { audioFile.name = it.first() }
comments["titlesort"]?.let { audioFile.sortName = it.first() }
// Track.
parseVorbisPositionField(
comments["tracknumber"]?.first(),
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
?.let { audioFile.track = it }
// Disc and it's subtitle name.
parseVorbisPositionField(
comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
?.let { audioFile.disc = it }
comments["discsubtitle"]?.let { audioFile.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 { audioFile.date = it }
// Album
(comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let {
audioFile.albumMusicBrainzId = it.first()
}
comments["album"]?.let { audioFile.albumName = it.first() }
comments["albumsort"]?.let { audioFile.albumSortName = it.first() }
(comments["releasetype"] ?: comments["musicbrainz album type"])?.let {
audioFile.releaseTypes = it
}
// Artist
(comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let {
audioFile.artistMusicBrainzIds = it
}
(comments["artists"] ?: comments["artist"])?.let { audioFile.artistNames = it }
(comments["artistssort"]
?: comments["artists_sort"]
?: comments["artists sort"]
?: comments["artistsort"]
?: comments["artist sort"])
?.let { audioFile.artistSortNames = it }
// Album artist
(comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
audioFile.albumArtistMusicBrainzIds = it
}
(comments["albumartists"]
?: comments["album_artists"]
?: comments["album artists"]
?: comments["albumartist"]
?: comments["album artist"])
?.let { audioFile.albumArtistNames = it }
(comments["albumartistssort"]
?: comments["albumartists_sort"]
?: comments["albumartists sort"]
?: comments["albumartistsort"]
?: comments["album artist sort"])
?.let { audioFile.albumArtistSortNames = it }
// Genre
comments["genre"]?.let { audioFile.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"
audioFile.albumArtistNames =
audioFile.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
audioFile.releaseTypes = audioFile.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 { audioFile.replayGainTrackAdjustment = it }
(comments["r128_album_gain"]?.parseR128Adjustment()
?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment())
?.let { audioFile.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.-]") }
}
}