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:
parent
73ff7e2c7f
commit
d633a6b9f1
8 changed files with 429 additions and 555 deletions
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)) }
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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" }
|
||||
}
|
|
@ -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.-]") }
|
|
@ -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.-]") }
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue