music: merge metadata into stack
This commit is contained in:
parent
cdc5a37bfa
commit
e51b2817e9
14 changed files with 21 additions and 675 deletions
|
@ -37,8 +37,7 @@ import org.oxycblt.auxio.music.cache.CacheRepository
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
import org.oxycblt.auxio.music.info.Name
|
import org.oxycblt.auxio.music.info.Name
|
||||||
import org.oxycblt.auxio.music.metadata.Separators
|
import org.oxycblt.auxio.music.stack.interpreter.Separators
|
||||||
import org.oxycblt.auxio.music.metadata.TagExtractor
|
|
||||||
import org.oxycblt.auxio.music.user.MutableUserLibrary
|
import org.oxycblt.auxio.music.user.MutableUserLibrary
|
||||||
import org.oxycblt.auxio.music.user.UserLibrary
|
import org.oxycblt.auxio.music.user.UserLibrary
|
||||||
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
|
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
|
||||||
|
|
|
@ -35,7 +35,7 @@ import org.oxycblt.auxio.music.stack.fs.Path
|
||||||
import org.oxycblt.auxio.music.stack.fs.contentResolverSafe
|
import org.oxycblt.auxio.music.stack.fs.contentResolverSafe
|
||||||
import org.oxycblt.auxio.music.stack.fs.useQuery
|
import org.oxycblt.auxio.music.stack.fs.useQuery
|
||||||
import org.oxycblt.auxio.music.info.Name
|
import org.oxycblt.auxio.music.info.Name
|
||||||
import org.oxycblt.auxio.music.metadata.Separators
|
import org.oxycblt.auxio.music.stack.interpreter.Separators
|
||||||
import org.oxycblt.auxio.util.forEachWithTimeout
|
import org.oxycblt.auxio.util.forEachWithTimeout
|
||||||
import org.oxycblt.auxio.util.sendWithTimeout
|
import org.oxycblt.auxio.util.sendWithTimeout
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
|
@ -36,8 +36,8 @@ import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
import org.oxycblt.auxio.music.info.Name
|
import org.oxycblt.auxio.music.info.Name
|
||||||
import org.oxycblt.auxio.music.info.ReleaseType
|
import org.oxycblt.auxio.music.info.ReleaseType
|
||||||
import org.oxycblt.auxio.music.metadata.Separators
|
import org.oxycblt.auxio.music.stack.interpreter.Separators
|
||||||
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
|
import org.oxycblt.auxio.music.stack.extractor.parseId3GenreNames
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
|
||||||
import org.oxycblt.auxio.util.positiveOrNull
|
import org.oxycblt.auxio.util.positiveOrNull
|
||||||
import org.oxycblt.auxio.util.toUuidOrNull
|
import org.oxycblt.auxio.util.toUuidOrNull
|
||||||
|
|
|
@ -31,7 +31,7 @@ import org.oxycblt.auxio.music.stack.fs.Components
|
||||||
import org.oxycblt.auxio.music.stack.fs.Path
|
import org.oxycblt.auxio.music.stack.fs.Path
|
||||||
import org.oxycblt.auxio.music.stack.fs.Volume
|
import org.oxycblt.auxio.music.stack.fs.Volume
|
||||||
import org.oxycblt.auxio.music.stack.fs.VolumeManager
|
import org.oxycblt.auxio.music.stack.fs.VolumeManager
|
||||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
import org.oxycblt.auxio.music.stack.extractor.correctWhitespace
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
||||||
import org.oxycblt.auxio.music.MusicSettings
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.stack.interpreter.Separators
|
||||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
|
|
|
@ -1,274 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 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.metadata
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.HandlerThread
|
|
||||||
import android.os.Message
|
|
||||||
import androidx.media3.common.C
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.Timeline
|
|
||||||
import androidx.media3.common.util.Clock
|
|
||||||
import androidx.media3.common.util.HandlerWrapper
|
|
||||||
import androidx.media3.exoplayer.LoadingInfo
|
|
||||||
import androidx.media3.exoplayer.analytics.PlayerId
|
|
||||||
import androidx.media3.exoplayer.source.MediaPeriod
|
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
|
||||||
import androidx.media3.exoplayer.source.MediaSource.Factory
|
|
||||||
import androidx.media3.exoplayer.source.TrackGroupArray
|
|
||||||
import androidx.media3.exoplayer.upstream.Allocator
|
|
||||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
|
||||||
import com.google.common.util.concurrent.SettableFuture
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.yield
|
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
|
||||||
import org.oxycblt.auxio.music.stack.fs.toAudioUri
|
|
||||||
import org.oxycblt.auxio.util.forEachWithTimeout
|
|
||||||
import org.oxycblt.auxio.util.sendWithTimeout
|
|
||||||
import timber.log.Timber as L
|
|
||||||
|
|
||||||
class TagExtractor
|
|
||||||
@Inject
|
|
||||||
constructor(private val mediaSourceFactory: Factory, private val tagInterpreter: TagInterpreter) {
|
|
||||||
suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>) {
|
|
||||||
val worker = MetadataWorker(mediaSourceFactory, tagInterpreter)
|
|
||||||
worker.start()
|
|
||||||
|
|
||||||
var songsIn = 0
|
|
||||||
incompleteSongs.forEachWithTimeout { incompleteRawSong ->
|
|
||||||
spin@ while (!worker.push(incompleteRawSong)) {
|
|
||||||
val completeRawSong = worker.pull()
|
|
||||||
if (completeRawSong != null) {
|
|
||||||
completeSongs.sendWithTimeout(completeRawSong)
|
|
||||||
yield()
|
|
||||||
songsIn--
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
songsIn++
|
|
||||||
}
|
|
||||||
|
|
||||||
L.d("All incomplete songs exhausted, starting cleanup loop")
|
|
||||||
while (!worker.idle()) {
|
|
||||||
val completeRawSong = worker.pull()
|
|
||||||
if (completeRawSong != null) {
|
|
||||||
completeSongs.sendWithTimeout(completeRawSong)
|
|
||||||
yield()
|
|
||||||
songsIn--
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
worker.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MESSAGE_CHECK_JOBS = 0
|
|
||||||
private const val MESSAGE_CONTINUE_LOADING = 1
|
|
||||||
private const val MESSAGE_RELEASE = 2
|
|
||||||
private const val MESSAGE_RELEASE_ALL = 3
|
|
||||||
private const val CHECK_INTERVAL_MS = 100
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patched version of Media3's MetadataRetriever that extracts metadata from several tracks at once
|
|
||||||
* on one thread. This is generally more efficient than stacking several threads at once.
|
|
||||||
*
|
|
||||||
* @author Media3 Team, Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
private class MetadataWorker(
|
|
||||||
private val mediaSourceFactory: Factory,
|
|
||||||
private val tagInterpreter: TagInterpreter
|
|
||||||
) : Handler.Callback {
|
|
||||||
private val mediaSourceThread = HandlerThread("Auxio:ChunkedMetadataRetriever")
|
|
||||||
private val mediaSourceHandler: HandlerWrapper
|
|
||||||
private val jobs = Array<MetadataJob?>(8) { null }
|
|
||||||
|
|
||||||
private class MetadataJob(
|
|
||||||
val rawSong: RawSong,
|
|
||||||
val mediaItem: MediaItem,
|
|
||||||
val future: SettableFuture<TrackGroupArray>,
|
|
||||||
var mediaSource: MediaSource?,
|
|
||||||
var mediaPeriod: MediaPeriod?,
|
|
||||||
var mediaSourceCaller: MediaSourceCaller?
|
|
||||||
)
|
|
||||||
|
|
||||||
init {
|
|
||||||
mediaSourceThread.start()
|
|
||||||
mediaSourceHandler = Clock.DEFAULT.createHandler(mediaSourceThread.looper, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
mediaSourceHandler.sendEmptyMessage(MESSAGE_CHECK_JOBS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun idle() = jobs.all { it == null }
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
mediaSourceHandler.sendEmptyMessage(MESSAGE_RELEASE_ALL)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun push(rawSong: RawSong): Boolean {
|
|
||||||
for (i in jobs.indices) {
|
|
||||||
if (jobs[i] == null) {
|
|
||||||
val uri =
|
|
||||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No URI" }.toAudioUri()
|
|
||||||
val job =
|
|
||||||
MetadataJob(
|
|
||||||
rawSong,
|
|
||||||
MediaItem.fromUri(uri),
|
|
||||||
SettableFuture.create<TrackGroupArray>(),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null)
|
|
||||||
jobs[i] = job
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pull(): RawSong? {
|
|
||||||
for (i in jobs.indices) {
|
|
||||||
val job = jobs[i]
|
|
||||||
if (job != null && job.future.isDone) {
|
|
||||||
try {
|
|
||||||
tagInterpreter.interpret(job.rawSong, job.future.get())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
L.e("Failed to extract metadata")
|
|
||||||
L.e(e.stackTraceToString())
|
|
||||||
}
|
|
||||||
jobs[i] = null
|
|
||||||
return job.rawSong
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleMessage(msg: Message): Boolean {
|
|
||||||
when (msg.what) {
|
|
||||||
MESSAGE_CHECK_JOBS -> {
|
|
||||||
for (job in jobs) {
|
|
||||||
if (job == null) continue
|
|
||||||
|
|
||||||
val currentMediaSource = job.mediaSource
|
|
||||||
val currentMediaSourceCaller = job.mediaSourceCaller
|
|
||||||
val mediaSource: MediaSource
|
|
||||||
val mediaSourceCaller: MediaSourceCaller
|
|
||||||
if (currentMediaSource != null && currentMediaSourceCaller != null) {
|
|
||||||
mediaSource = currentMediaSource
|
|
||||||
mediaSourceCaller = currentMediaSourceCaller
|
|
||||||
} else {
|
|
||||||
L.d("new media source yahoo")
|
|
||||||
mediaSource = mediaSourceFactory.createMediaSource(job.mediaItem)
|
|
||||||
mediaSourceCaller = MediaSourceCaller(job)
|
|
||||||
mediaSource.prepareSource(
|
|
||||||
mediaSourceCaller, /* mediaTransferListener= */ null, PlayerId.UNSET)
|
|
||||||
job.mediaSource = mediaSource
|
|
||||||
job.mediaSourceCaller = mediaSourceCaller
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val mediaPeriod = job.mediaPeriod
|
|
||||||
if (mediaPeriod == null) {
|
|
||||||
mediaSource.maybeThrowSourceInfoRefreshError()
|
|
||||||
} else {
|
|
||||||
mediaPeriod.maybeThrowPrepareError()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
L.e("Failed to extract MediaSource")
|
|
||||||
L.e(e.stackTraceToString())
|
|
||||||
job.mediaPeriod?.let(mediaSource::releasePeriod)
|
|
||||||
mediaSource.releaseSource(mediaSourceCaller)
|
|
||||||
job.future.setException(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaSourceHandler.sendEmptyMessageDelayed(
|
|
||||||
MESSAGE_CHECK_JOBS, /* delayMs= */ CHECK_INTERVAL_MS)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
MESSAGE_CONTINUE_LOADING -> {
|
|
||||||
checkNotNull((msg.obj as MetadataJob).mediaPeriod)
|
|
||||||
.continueLoading(LoadingInfo.Builder().setPlaybackPositionUs(0).build())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
MESSAGE_RELEASE -> {
|
|
||||||
val job = msg.obj as MetadataJob
|
|
||||||
job.mediaPeriod?.let { job.mediaSource?.releasePeriod(it) }
|
|
||||||
job.mediaSourceCaller?.let { job.mediaSource?.releaseSource(it) }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
MESSAGE_RELEASE_ALL -> {
|
|
||||||
for (job in jobs) {
|
|
||||||
if (job == null) continue
|
|
||||||
job.mediaPeriod?.let { job.mediaSource?.releasePeriod(it) }
|
|
||||||
job.mediaSourceCaller?.let { job.mediaSource?.releaseSource(it) }
|
|
||||||
}
|
|
||||||
mediaSourceHandler.removeCallbacksAndMessages(/* token= */ null)
|
|
||||||
mediaSourceThread.quit()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class MediaSourceCaller(private val job: MetadataJob) :
|
|
||||||
MediaSource.MediaSourceCaller {
|
|
||||||
|
|
||||||
private val mediaPeriodCallback: MediaPeriodCallback = MediaPeriodCallback(job)
|
|
||||||
private val allocator: Allocator =
|
|
||||||
DefaultAllocator(
|
|
||||||
/* trimOnReset= */ true,
|
|
||||||
/* individualAllocationSize= */ C.DEFAULT_BUFFER_SEGMENT_SIZE)
|
|
||||||
|
|
||||||
private var mediaPeriodCreated = false
|
|
||||||
|
|
||||||
override fun onSourceInfoRefreshed(source: MediaSource, timeline: Timeline) {
|
|
||||||
if (mediaPeriodCreated) {
|
|
||||||
// Ignore dynamic updates.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
L.d("yay source created")
|
|
||||||
mediaPeriodCreated = true
|
|
||||||
val mediaPeriod =
|
|
||||||
source.createPeriod(
|
|
||||||
MediaSource.MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ 0)),
|
|
||||||
allocator,
|
|
||||||
/* startPositionUs= */ 0)
|
|
||||||
job.mediaPeriod = mediaPeriod
|
|
||||||
mediaPeriod.prepare(mediaPeriodCallback, /* positionUs= */ 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class MediaPeriodCallback(private val job: MetadataJob) :
|
|
||||||
MediaPeriod.Callback {
|
|
||||||
override fun onPrepared(mediaPeriod: MediaPeriod) {
|
|
||||||
job.future.set(mediaPeriod.getTrackGroups())
|
|
||||||
mediaSourceHandler.obtainMessage(MESSAGE_RELEASE, job).sendToTarget()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
override fun onContinueLoadingRequested(source: MediaPeriod) {
|
|
||||||
mediaSourceHandler.obtainMessage(MESSAGE_CONTINUE_LOADING, job).sendToTarget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,370 +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.metadata
|
|
||||||
|
|
||||||
import androidx.core.text.isDigitsOnly
|
|
||||||
import androidx.media3.exoplayer.MetadataRetriever
|
|
||||||
import androidx.media3.exoplayer.source.TrackGroupArray
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.math.min
|
|
||||||
import org.oxycblt.auxio.image.extractor.CoverExtractor
|
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
|
||||||
import org.oxycblt.auxio.music.info.Date
|
|
||||||
import org.oxycblt.auxio.music.stack.extractor.TextTags
|
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
|
||||||
import timber.log.Timber as L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An processing abstraction over the [MetadataRetriever] and [TextTags] workflow that operates on
|
|
||||||
* [RawSong] instances.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
interface TagInterpreter {
|
|
||||||
/**
|
|
||||||
* Poll to see if this worker is done processing.
|
|
||||||
*
|
|
||||||
* @return A completed [RawSong] if done, null otherwise.
|
|
||||||
*/
|
|
||||||
fun interpret(rawSong: RawSong, trackGroupArray: TrackGroupArray)
|
|
||||||
}
|
|
||||||
|
|
||||||
class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverExtractor) :
|
|
||||||
TagInterpreter {
|
|
||||||
override fun interpret(rawSong: RawSong, trackGroupArray: TrackGroupArray) {
|
|
||||||
val format = trackGroupArray.get(0).getFormat(0)
|
|
||||||
val metadata = format.metadata
|
|
||||||
if (metadata != null) {
|
|
||||||
val textTags = TextTags(metadata)
|
|
||||||
populateWithId3v2(rawSong, textTags.id3v2)
|
|
||||||
populateWithVorbis(rawSong, textTags.vorbis)
|
|
||||||
|
|
||||||
coverExtractor.findCoverDataInMetadata(metadata)?.use {
|
|
||||||
val available = it.available()
|
|
||||||
val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong())
|
|
||||||
it.skip(skip)
|
|
||||||
val bytes = ByteArray(COVER_KEY_SAMPLE)
|
|
||||||
it.read(bytes)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalStdlibApi::class) val byteString = bytes.toHexString()
|
|
||||||
|
|
||||||
rawSong.coverPerceptualHash = byteString
|
|
||||||
}
|
|
||||||
|
|
||||||
// OPTIONAL: Nicer cover art keying using an actual perceptual hash
|
|
||||||
// Really bad idea if you have big cover arts. Okay idea if you have different
|
|
||||||
// formats for the same cover art.
|
|
||||||
// val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) }
|
|
||||||
// rawSong.coverPerceptualHash = bitmap?.dHash()
|
|
||||||
// bitmap?.recycle()
|
|
||||||
|
|
||||||
// OPUS base gain interpretation code: This is likely not needed, as the media player
|
|
||||||
// should be using the base gain already. Uncomment if that's not the case.
|
|
||||||
// if (format.sampleMimeType == MimeTypes.AUDIO_OPUS
|
|
||||||
// && format.initializationData.isNotEmpty()
|
|
||||||
// && format.initializationData[0].size >= 18) {
|
|
||||||
// val header = format.initializationData[0]
|
|
||||||
// val gain =
|
|
||||||
// (((header[16]).toInt() and 0xFF) or ((header[17].toInt() shl 8)))
|
|
||||||
// .R128ToLUFS18()
|
|
||||||
// L.d("Obtained opus base gain: $gain dB")
|
|
||||||
// if (gain != 0f) {
|
|
||||||
// L.d("Applying opus base gain")
|
|
||||||
// rawSong.replayGainTrackAdjustment =
|
|
||||||
// (rawSong.replayGainTrackAdjustment ?: 0f) + gain
|
|
||||||
// rawSong.replayGainAlbumAdjustment =
|
|
||||||
// (rawSong.replayGainAlbumAdjustment ?: 0f) + gain
|
|
||||||
// } else {
|
|
||||||
// L.d("Ignoring opus base gain")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
} else {
|
|
||||||
L.d("No metadata could be extracted for ${rawSong.name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun populateWithId3v2(rawSong: RawSong, textFrames: Map<String, List<String>>) {
|
|
||||||
// Song
|
|
||||||
(textFrames["TXXX:musicbrainz release track id"]
|
|
||||||
?: textFrames["TXXX:musicbrainz_releasetrackid"])
|
|
||||||
?.let { rawSong.musicBrainzId = it.first() }
|
|
||||||
textFrames["TIT2"]?.let { rawSong.name = it.first() }
|
|
||||||
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
|
|
||||||
|
|
||||||
// Track.
|
|
||||||
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { rawSong.track = it }
|
|
||||||
|
|
||||||
// Disc and it's subtitle name.
|
|
||||||
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { rawSong.disc = it }
|
|
||||||
textFrames["TSST"]?.let { rawSong.subtitle = it.first() }
|
|
||||||
|
|
||||||
// Dates are somewhat complicated, as not only did their semantics change from a flat year
|
|
||||||
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
|
|
||||||
// date types.
|
|
||||||
// Our hierarchy for dates is as such:
|
|
||||||
// 1. ID3v2.4 Original Date, as it resolves the "Released in X, Remastered in Y" issue
|
|
||||||
// 2. ID3v2.4 Recording Date, as it is the most common date type
|
|
||||||
// 3. ID3v2.4 Release Date, as it is the second most common date type
|
|
||||||
// 4. ID3v2.3 Original Date, as it is like #1
|
|
||||||
// 5. ID3v2.3 Release Year, as it is the most common date type
|
|
||||||
// TODO: Show original and normal dates side-by-side
|
|
||||||
// TODO: Handle dates that are in "January" because the actual specific release date
|
|
||||||
// isn't known?
|
|
||||||
(textFrames["TDOR"]?.run { Date.from(first()) }
|
|
||||||
?: textFrames["TDRC"]?.run { Date.from(first()) }
|
|
||||||
?: textFrames["TDRL"]?.run { Date.from(first()) }
|
|
||||||
?: parseId3v23Date(textFrames))
|
|
||||||
?.let { rawSong.date = it }
|
|
||||||
|
|
||||||
// Album
|
|
||||||
(textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let {
|
|
||||||
rawSong.albumMusicBrainzId = it.first()
|
|
||||||
}
|
|
||||||
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
|
|
||||||
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
|
|
||||||
(textFrames["TXXX:musicbrainz album type"]
|
|
||||||
?: textFrames["TXXX:releasetype"]
|
|
||||||
?:
|
|
||||||
// This is a non-standard iTunes extension
|
|
||||||
textFrames["GRP1"])
|
|
||||||
?.let { rawSong.releaseTypes = it }
|
|
||||||
|
|
||||||
// Artist
|
|
||||||
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
|
|
||||||
rawSong.artistMusicBrainzIds = it
|
|
||||||
}
|
|
||||||
(textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let {
|
|
||||||
rawSong.artistNames = it
|
|
||||||
}
|
|
||||||
(textFrames["TXXX:artistssort"]
|
|
||||||
?: textFrames["TXXX:artists_sort"]
|
|
||||||
?: textFrames["TXXX:artists sort"]
|
|
||||||
?: textFrames["TSOP"]
|
|
||||||
?: textFrames["artistsort"]
|
|
||||||
?: textFrames["TXXX:artist sort"])
|
|
||||||
?.let { rawSong.artistSortNames = it }
|
|
||||||
|
|
||||||
// Album artist
|
|
||||||
(textFrames["TXXX:musicbrainz album artist id"]
|
|
||||||
?: textFrames["TXXX:musicbrainz_albumartistid"])
|
|
||||||
?.let { rawSong.albumArtistMusicBrainzIds = it }
|
|
||||||
(textFrames["TXXX:albumartists"]
|
|
||||||
?: textFrames["TXXX:album_artists"]
|
|
||||||
?: textFrames["TXXX:album artists"]
|
|
||||||
?: textFrames["TPE2"]
|
|
||||||
?: textFrames["TXXX:albumartist"]
|
|
||||||
?: textFrames["TXXX:album artist"])
|
|
||||||
?.let { rawSong.albumArtistNames = it }
|
|
||||||
(textFrames["TXXX:albumartistssort"]
|
|
||||||
?: textFrames["TXXX:albumartists_sort"]
|
|
||||||
?: textFrames["TXXX:albumartists sort"]
|
|
||||||
?: textFrames["TXXX:albumartistsort"]
|
|
||||||
// This is a non-standard iTunes extension
|
|
||||||
?: textFrames["TSO2"]
|
|
||||||
?: textFrames["TXXX:album artist sort"])
|
|
||||||
?.let { rawSong.albumArtistSortNames = it }
|
|
||||||
|
|
||||||
// Genre
|
|
||||||
textFrames["TCON"]?.let { rawSong.genreNames = it }
|
|
||||||
|
|
||||||
// Compilation Flag
|
|
||||||
(textFrames["TCMP"] // This is a non-standard itunes extension
|
|
||||||
?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"])
|
|
||||||
?.let {
|
|
||||||
// Ignore invalid instances of this tag
|
|
||||||
if (it.size != 1 || it[0] != "1") return@let
|
|
||||||
// Change the metadata to be a compilation album made by "Various Artists"
|
|
||||||
rawSong.albumArtistNames =
|
|
||||||
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
|
|
||||||
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplayGain information
|
|
||||||
textFrames["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()?.let {
|
|
||||||
rawSong.replayGainTrackAdjustment = it
|
|
||||||
}
|
|
||||||
textFrames["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()?.let {
|
|
||||||
rawSong.replayGainAlbumAdjustment = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
|
|
||||||
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
|
||||||
// is present.
|
|
||||||
val year =
|
|
||||||
textFrames["TORY"]?.run { first().toIntOrNull() }
|
|
||||||
?: textFrames["TYER"]?.run { first().toIntOrNull() }
|
|
||||||
?: return null
|
|
||||||
|
|
||||||
val tdat = textFrames["TDAT"]
|
|
||||||
return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) {
|
|
||||||
// TDAT frames consist of a 4-digit string where the first two digits are
|
|
||||||
// the month and the last two digits are the day.
|
|
||||||
val mm = tdat.first().substring(0..1).toInt()
|
|
||||||
val dd = tdat.first().substring(2..3).toInt()
|
|
||||||
|
|
||||||
val time = textFrames["TIME"]
|
|
||||||
if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) {
|
|
||||||
// TIME frames consist of a 4-digit string where the first two digits are
|
|
||||||
// the hour and the last two digits are the minutes. No second value is
|
|
||||||
// possible.
|
|
||||||
val hh = time.first().substring(0..1).toInt()
|
|
||||||
val mi = time.first().substring(2..3).toInt()
|
|
||||||
// Able to return a full date.
|
|
||||||
Date.from(year, mm, dd, hh, mi)
|
|
||||||
} else {
|
|
||||||
// Unable to parse time, just return a date
|
|
||||||
Date.from(year, mm, dd)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Unable to parse month/day, just return a year
|
|
||||||
return Date.from(year)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun populateWithVorbis(rawSong: RawSong, comments: Map<String, List<String>>) {
|
|
||||||
// Song
|
|
||||||
(comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let {
|
|
||||||
rawSong.musicBrainzId = it.first()
|
|
||||||
}
|
|
||||||
comments["title"]?.let { rawSong.name = it.first() }
|
|
||||||
comments["titlesort"]?.let { rawSong.sortName = it.first() }
|
|
||||||
|
|
||||||
// Track.
|
|
||||||
parseVorbisPositionField(
|
|
||||||
comments["tracknumber"]?.first(),
|
|
||||||
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
|
|
||||||
?.let { rawSong.track = it }
|
|
||||||
|
|
||||||
// Disc and it's subtitle name.
|
|
||||||
parseVorbisPositionField(
|
|
||||||
comments["discnumber"]?.first(),
|
|
||||||
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
|
|
||||||
?.let { rawSong.disc = it }
|
|
||||||
comments["discsubtitle"]?.let { rawSong.subtitle = it.first() }
|
|
||||||
|
|
||||||
// Vorbis dates are less complicated, but there are still several types
|
|
||||||
// Our hierarchy for dates is as such:
|
|
||||||
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
|
|
||||||
// 2. Date, as it is the most common date type
|
|
||||||
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
|
|
||||||
// date tag that android supports, so it must be 15 years old or more!)
|
|
||||||
(comments["originaldate"]?.run { Date.from(first()) }
|
|
||||||
?: comments["date"]?.run { Date.from(first()) }
|
|
||||||
?: comments["year"]?.run { Date.from(first()) })
|
|
||||||
?.let { rawSong.date = it }
|
|
||||||
|
|
||||||
// Album
|
|
||||||
(comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let {
|
|
||||||
rawSong.albumMusicBrainzId = it.first()
|
|
||||||
}
|
|
||||||
comments["album"]?.let { rawSong.albumName = it.first() }
|
|
||||||
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
|
|
||||||
(comments["releasetype"] ?: comments["musicbrainz album type"])?.let {
|
|
||||||
rawSong.releaseTypes = it
|
|
||||||
}
|
|
||||||
|
|
||||||
// Artist
|
|
||||||
(comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let {
|
|
||||||
rawSong.artistMusicBrainzIds = it
|
|
||||||
}
|
|
||||||
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
|
|
||||||
(comments["artistssort"]
|
|
||||||
?: comments["artists_sort"]
|
|
||||||
?: comments["artists sort"]
|
|
||||||
?: comments["artistsort"]
|
|
||||||
?: comments["artist sort"])
|
|
||||||
?.let { rawSong.artistSortNames = it }
|
|
||||||
|
|
||||||
// Album artist
|
|
||||||
(comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
|
|
||||||
rawSong.albumArtistMusicBrainzIds = it
|
|
||||||
}
|
|
||||||
(comments["albumartists"]
|
|
||||||
?: comments["album_artists"]
|
|
||||||
?: comments["album artists"]
|
|
||||||
?: comments["albumartist"]
|
|
||||||
?: comments["album artist"])
|
|
||||||
?.let { rawSong.albumArtistNames = it }
|
|
||||||
(comments["albumartistssort"]
|
|
||||||
?: comments["albumartists_sort"]
|
|
||||||
?: comments["albumartists sort"]
|
|
||||||
?: comments["albumartistsort"]
|
|
||||||
?: comments["album artist sort"])
|
|
||||||
?.let { rawSong.albumArtistSortNames = it }
|
|
||||||
|
|
||||||
// Genre
|
|
||||||
comments["genre"]?.let { rawSong.genreNames = it }
|
|
||||||
|
|
||||||
// Compilation Flag
|
|
||||||
(comments["compilation"] ?: comments["itunescompilation"])?.let {
|
|
||||||
// Ignore invalid instances of this tag
|
|
||||||
if (it.size != 1 || it[0] != "1") return@let
|
|
||||||
// Change the metadata to be a compilation album made by "Various Artists"
|
|
||||||
rawSong.albumArtistNames =
|
|
||||||
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
|
|
||||||
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplayGain information
|
|
||||||
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
|
|
||||||
// replaygain_*_gain tag, but opus has it's own "r128_*_gain" ReplayGain specification,
|
|
||||||
// which requires dividing the adjustment by 256 to get the gain. This is used alongside
|
|
||||||
// the base adjustment intrinsic to the format to create the normalized adjustment. This is
|
|
||||||
// normally the only tag used for opus files, but some software still writes replay gain
|
|
||||||
// tags anyway.
|
|
||||||
(comments["r128_track_gain"]?.parseR128Adjustment()
|
|
||||||
?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment())
|
|
||||||
?.let { rawSong.replayGainTrackAdjustment = it }
|
|
||||||
(comments["r128_album_gain"]?.parseR128Adjustment()
|
|
||||||
?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment())
|
|
||||||
?.let { rawSong.replayGainAlbumAdjustment = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<String>.parseR128Adjustment() =
|
|
||||||
first()
|
|
||||||
.replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "")
|
|
||||||
.toFloatOrNull()
|
|
||||||
?.nonZeroOrNull()
|
|
||||||
?.run {
|
|
||||||
// Convert to fixed-point and adjust to LUFS 18 to match the ReplayGain scale
|
|
||||||
this / 256f + 5
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a ReplayGain adjustment into a float value.
|
|
||||||
*
|
|
||||||
* @return A parsed adjustment float, or null if the adjustment had invalid formatting.
|
|
||||||
*/
|
|
||||||
private fun List<String>.parseReplayGainAdjustment() =
|
|
||||||
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull()
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val COVER_KEY_SAMPLE = 32
|
|
||||||
|
|
||||||
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
|
|
||||||
val COMPILATION_RELEASE_TYPES = listOf("compilation")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
|
|
||||||
* https://github.com/vanilla-music/vanilla
|
|
||||||
*/
|
|
||||||
val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -30,8 +30,8 @@ import androidx.room.TypeConverter
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
import org.oxycblt.auxio.music.info.Date
|
import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
import org.oxycblt.auxio.music.stack.extractor.correctWhitespace
|
||||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
import org.oxycblt.auxio.music.stack.extractor.splitEscaped
|
||||||
|
|
||||||
@Database(entities = [Tags::class], version = 50, exportSchema = false)
|
@Database(entities = [Tags::class], version = 50, exportSchema = false)
|
||||||
abstract class TagDatabase : RoomDatabase() {
|
abstract class TagDatabase : RoomDatabase() {
|
||||||
|
|
|
@ -24,8 +24,6 @@ import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.image.extractor.CoverExtractor
|
import org.oxycblt.auxio.image.extractor.CoverExtractor
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
import org.oxycblt.auxio.music.info.Date
|
import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.metadata.parseId3v2PositionField
|
|
||||||
import org.oxycblt.auxio.music.metadata.parseVorbisPositionField
|
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.music.stack.extractor
|
||||||
|
|
||||||
import org.oxycblt.auxio.util.positiveOrNull
|
import org.oxycblt.auxio.util.positiveOrNull
|
||||||
|
|
|
@ -22,7 +22,6 @@ import androidx.media3.common.Metadata
|
||||||
import androidx.media3.extractor.metadata.id3.InternalFrame
|
import androidx.media3.extractor.metadata.id3.InternalFrame
|
||||||
import androidx.media3.extractor.metadata.id3.TextInformationFrame
|
import androidx.media3.extractor.metadata.id3.TextInformationFrame
|
||||||
import androidx.media3.extractor.metadata.vorbis.VorbisComment
|
import androidx.media3.extractor.metadata.vorbis.VorbisComment
|
||||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags.
|
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags.
|
||||||
|
|
|
@ -1,22 +1,9 @@
|
||||||
/*
|
package org.oxycblt.auxio.music.stack.interpreter
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* Separators.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
import org.oxycblt.auxio.music.metadata.CharSeparators
|
||||||
|
import org.oxycblt.auxio.music.metadata.NoSeparators
|
||||||
|
import org.oxycblt.auxio.music.stack.extractor.correctWhitespace
|
||||||
|
import org.oxycblt.auxio.music.stack.extractor.splitEscaped
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the user-specified parsing of multi-value tags. This should be used to parse any tags
|
* Defines the user-specified parsing of multi-value tags. This should be used to parse any tags
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.metadata
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.oxycblt.auxio.music.stack.interpreter.Separators
|
||||||
|
|
||||||
class SeparatorsTest {
|
class SeparatorsTest {
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -20,6 +20,11 @@ package org.oxycblt.auxio.music.metadata
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.oxycblt.auxio.music.stack.extractor.correctWhitespace
|
||||||
|
import org.oxycblt.auxio.music.stack.extractor.parseId3GenreNames
|
||||||
|
import org.oxycblt.auxio.music.stack.extractor.parseId3v2PositionField
|
||||||
|
import org.oxycblt.auxio.music.stack.extractor.parseVorbisPositionField
|
||||||
|
import org.oxycblt.auxio.music.stack.extractor.splitEscaped
|
||||||
|
|
||||||
class TagUtilTest {
|
class TagUtilTest {
|
||||||
@Test
|
@Test
|
||||||
|
|
Loading…
Reference in a new issue