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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
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.extractor.ExoPlayerTagExtractor
import org.oxycblt.auxio.music.stack.explore.extractor.TagResult
import org.oxycblt.auxio.music.stack.explore.extractor.TagExtractor
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.playlists.StoredPlaylists
import javax.inject.Inject
@ -34,23 +38,35 @@ data class Files(
class ExplorerImpl @Inject constructor(
private val deviceFiles: DeviceFiles,
private val tagCache: TagCache,
private val tagExtractor: ExoPlayerTagExtractor,
private val tagExtractor: TagExtractor,
private val storedPlaylists: StoredPlaylists
) : Explorer {
@OptIn(ExperimentalCoroutinesApi::class)
override fun explore(uris: List<Uri>): Files {
val deviceFiles = deviceFiles.explore(uris.asFlow()).flowOn(Dispatchers.IO).buffer()
val tagRead = tagCache.read(deviceFiles).flowOn(Dispatchers.IO).buffer()
val (cacheFiles, cacheSongs) = tagRead.results()
val tagExtractor = tagExtractor.process(cacheFiles).flowOn(Dispatchers.IO).buffer()
val (_, extractorSongs) = tagExtractor.results()
val writtenExtractorSongs = tagCache.write(extractorSongs).flowOn(Dispatchers.IO).buffer()
val (uncachedDeviceFiles, cachedAudioFiles) = tagRead.results()
val extractedAudioFiles = uncachedDeviceFiles.split(8).map {
tagExtractor.extract(it).flowOn(Dispatchers.IO).buffer()
}.asFlow().flattenMerge()
val writtenAudioFiles = tagCache.write(extractedAudioFiles).flowOn(Dispatchers.IO).buffer()
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>> {
val files = filterIsInstance<TagResult.Miss>().map { it.file }
val songs = filterIsInstance<TagResult.Hit>().map { it.audioFile }
private fun Flow<CacheResult>.results(): Pair<Flow<DeviceFile>, Flow<AudioFile>> {
val shared = shareIn(CoroutineScope(Dispatchers.Main), SharingStarted.WhileSubscribed(), replay = 0)
val files = shared.filterIsInstance<CacheResult.Miss>().map { it.deviceFile }
val songs = shared.filterIsInstance<CacheResult.Hit>().map { it.audioFile }
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(
val deviceFile: DeviceFile,
var durationMs: Long? = null,
var replayGainTrackAdjustment: Float? = null,
var replayGainAlbumAdjustment: Float? = null,
var musicBrainzId: String? = null,
var name: String? = null,
var sortName: String? = null,
var track: Int? = null,
var disc: Int? = null,
var subtitle: String? = null,
var date: Date? = null,
var albumMusicBrainzId: String? = null,
var albumName: String? = null,
var albumSortName: String? = null,
var releaseTypes: List<String> = listOf(),
var artistMusicBrainzIds: List<String> = listOf(),
var artistNames: List<String> = listOf(),
var artistSortNames: List<String> = listOf(),
var albumArtistMusicBrainzIds: List<String> = listOf(),
var albumArtistNames: List<String> = listOf(),
var albumArtistSortNames: List<String> = listOf(),
var genreNames: List<String> = listOf()
val durationMs: Long,
val replayGainTrackAdjustment: Float? = null,
val replayGainAlbumAdjustment: Float? = null,
val musicBrainzId: String? = null,
val name: String,
val sortName: String? = null,
val track: Int? = null,
val disc: Int? = null,
val subtitle: String? = null,
val date: Date? = null,
val albumMusicBrainzId: String? = null,
val albumName: String? = null,
val albumSortName: String? = null,
val releaseTypes: List<String> = listOf(),
val artistMusicBrainzIds: List<String> = listOf(),
val artistNames: List<String> = listOf(),
val artistSortNames: List<String> = listOf(),
val albumArtistMusicBrainzIds: List<String> = listOf(),
val albumArtistNames: List<String> = listOf(),
val albumArtistSortNames: List<String> = listOf(),
val genreNames: List<String> = listOf()
)
data class PlaylistFile(

View file

@ -23,28 +23,29 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
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
sealed interface CacheResult {
data class Hit(val audioFile: AudioFile) : CacheResult
data class Miss(val deviceFile: DeviceFile) : CacheResult
}
interface TagCache {
fun read(files: Flow<DeviceFile>): Flow<TagResult>
fun read(files: Flow<DeviceFile>): Flow<CacheResult>
fun write(rawSongs: Flow<AudioFile>): Flow<AudioFile>
}
class TagCacheImpl @Inject constructor(private val tagDao: TagDao) : TagCache {
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)
if (tags != null) {
val audioFile = AudioFile(deviceFile = file)
tags.copyToRaw(audioFile)
TagResult.Hit(audioFile)
CacheResult.Hit(tags.toAudioFile(file))
} else {
TagResult.Miss(file)
CacheResult.Miss(file)
}
}
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 org.oxycblt.auxio.music.stack.explore.AudioFile
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.splitEscaped
@ -59,76 +60,70 @@ data class Tags(
/** @see AudioFile */
val durationMs: Long,
/** @see AudioFile.replayGainTrackAdjustment */
val replayGainTrackAdjustment: Float? = null,
val replayGainTrackAdjustment: Float?,
/** @see AudioFile.replayGainAlbumAdjustment */
val replayGainAlbumAdjustment: Float? = null,
val replayGainAlbumAdjustment: Float?,
/** @see AudioFile.musicBrainzId */
var musicBrainzId: String? = null,
val musicBrainzId: String?,
/** @see AudioFile.name */
var name: String,
val name: String,
/** @see AudioFile.sortName */
var sortName: String? = null,
val sortName: String?,
/** @see AudioFile.track */
var track: Int? = null,
val track: Int?,
/** @see AudioFile.name */
var disc: Int? = null,
val disc: Int?,
/** @See AudioFile.subtitle */
var subtitle: String? = null,
val subtitle: String?,
/** @see AudioFile.date */
var date: Date? = null,
val date: Date?,
/** @see AudioFile.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
val albumMusicBrainzId: String?,
/** @see AudioFile.albumName */
var albumName: String,
val albumName: String?,
/** @see AudioFile.albumSortName */
var albumSortName: String? = null,
val albumSortName: String?,
/** @see AudioFile.releaseTypes */
var releaseTypes: List<String> = listOf(),
val releaseTypes: List<String> = listOf(),
/** @see AudioFile.artistMusicBrainzIds */
var artistMusicBrainzIds: List<String> = listOf(),
val artistMusicBrainzIds: List<String> = listOf(),
/** @see AudioFile.artistNames */
var artistNames: List<String> = listOf(),
val artistNames: List<String> = listOf(),
/** @see AudioFile.artistSortNames */
var artistSortNames: List<String> = listOf(),
val artistSortNames: List<String> = listOf(),
/** @see AudioFile.albumArtistMusicBrainzIds */
var albumArtistMusicBrainzIds: List<String> = listOf(),
val albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see AudioFile.albumArtistNames */
var albumArtistNames: List<String> = listOf(),
val albumArtistNames: List<String> = listOf(),
/** @see AudioFile.albumArtistSortNames */
var albumArtistSortNames: List<String> = listOf(),
val albumArtistSortNames: List<String> = listOf(),
/** @see AudioFile.genreNames */
var genreNames: List<String> = listOf()
val genreNames: List<String> = listOf()
) {
fun copyToRaw(audioFile: AudioFile) {
audioFile.musicBrainzId = musicBrainzId
audioFile.name = name
audioFile.sortName = sortName
audioFile.durationMs = durationMs
audioFile.replayGainTrackAdjustment = replayGainTrackAdjustment
audioFile.replayGainAlbumAdjustment = replayGainAlbumAdjustment
audioFile.track = track
audioFile.disc = disc
audioFile.subtitle = subtitle
audioFile.date = date
audioFile.albumMusicBrainzId = albumMusicBrainzId
audioFile.albumName = albumName
audioFile.albumSortName = albumSortName
audioFile.releaseTypes = releaseTypes
audioFile.artistMusicBrainzIds = artistMusicBrainzIds
audioFile.artistNames = artistNames
audioFile.artistSortNames = artistSortNames
audioFile.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
audioFile.albumArtistNames = albumArtistNames
audioFile.albumArtistSortNames = albumArtistSortNames
audioFile.genreNames = genreNames
}
fun toAudioFile(deviceFile: DeviceFile) =
AudioFile(
deviceFile = deviceFile,
musicBrainzId = musicBrainzId,
name = name,
sortName = sortName,
durationMs = durationMs,
replayGainTrackAdjustment = replayGainTrackAdjustment,
replayGainAlbumAdjustment = replayGainAlbumAdjustment,
track = track,
disc = disc,
subtitle = subtitle,
date = date,
albumMusicBrainzId = albumMusicBrainzId,
albumName = albumName,
albumSortName = albumSortName,
releaseTypes = releaseTypes,
artistMusicBrainzIds = artistMusicBrainzIds,
artistNames = artistNames,
artistSortNames = artistSortNames,
albumArtistMusicBrainzIds = albumArtistMusicBrainzIds,
albumArtistNames = albumArtistNames,
albumArtistSortNames = albumArtistSortNames,
genreNames = genreNames)
object Converters {
@TypeConverter
@ -144,14 +139,14 @@ data class Tags(
}
companion object {
fun fromRaw(audioFile: AudioFile) =
fun fromAudioFile(audioFile: AudioFile) =
Tags(
uri = audioFile.deviceFile.uri.toString(),
dateModified = audioFile.deviceFile.lastModified,
musicBrainzId = audioFile.musicBrainzId,
name = requireNotNull(audioFile.name) { "Invalid raw: No name" },
name = audioFile.name,
sortName = audioFile.sortName,
durationMs = requireNotNull(audioFile.durationMs) { "Invalid raw: No duration" },
durationMs = audioFile.durationMs,
replayGainTrackAdjustment = audioFile.replayGainTrackAdjustment,
replayGainAlbumAdjustment = audioFile.replayGainAlbumAdjustment,
track = audioFile.track,
@ -159,7 +154,7 @@ data class Tags(
subtitle = audioFile.subtitle,
date = audioFile.date,
albumMusicBrainzId = audioFile.albumMusicBrainzId,
albumName = requireNotNull(audioFile.albumName) { "Invalid raw: No album name" },
albumName = audioFile.albumName,
albumSortName = audioFile.albumSortName,
releaseTypes = audioFile.releaseTypes,
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.-]") }
}
}