musikr: start using ktaglib
This commit is contained in:
parent
2f98d67855
commit
65151e006f
13 changed files with 114 additions and 713 deletions
|
@ -1,30 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* CoverModule.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.musikr.cover
|
|
||||||
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface CoverModule {
|
|
||||||
@Binds fun coverParser(impl: CoverParserImpl): CoverParser
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* CoverParser.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.musikr.cover
|
|
||||||
|
|
||||||
import androidx.media3.common.MediaMetadata
|
|
||||||
import androidx.media3.common.Metadata
|
|
||||||
import androidx.media3.extractor.metadata.flac.PictureFrame
|
|
||||||
import androidx.media3.extractor.metadata.id3.ApicFrame
|
|
||||||
import javax.inject.Inject
|
|
||||||
import org.oxycblt.musikr.metadata.AudioMetadata
|
|
||||||
|
|
||||||
interface CoverParser {
|
|
||||||
suspend fun extract(metadata: AudioMetadata): ByteArray?
|
|
||||||
}
|
|
||||||
|
|
||||||
class CoverParserImpl @Inject constructor() : CoverParser {
|
|
||||||
override suspend fun extract(metadata: AudioMetadata): ByteArray? {
|
|
||||||
val exoPlayerMetadata = metadata.exoPlayerFormat?.metadata
|
|
||||||
return exoPlayerMetadata?.let(::findCoverDataInMetadata)
|
|
||||||
?: metadata.mediaMetadataRetriever.embeddedPicture
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findCoverDataInMetadata(metadata: Metadata): ByteArray? {
|
|
||||||
var fallbackPic: ByteArray? = null
|
|
||||||
|
|
||||||
for (i in 0 until metadata.length()) {
|
|
||||||
// We can only extract pictures from two tags with this method, ID3v2's APIC or
|
|
||||||
// Vorbis picture comments.
|
|
||||||
val pic: ByteArray?
|
|
||||||
val type: Int
|
|
||||||
|
|
||||||
when (val entry = metadata.get(i)) {
|
|
||||||
is ApicFrame -> {
|
|
||||||
pic = entry.pictureData
|
|
||||||
type = entry.pictureType
|
|
||||||
}
|
|
||||||
is PictureFrame -> {
|
|
||||||
pic = entry.pictureData
|
|
||||||
type = entry.pictureType
|
|
||||||
}
|
|
||||||
else -> continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
|
|
||||||
return pic
|
|
||||||
} else if (fallbackPic == null) {
|
|
||||||
fallbackPic = pic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackPic
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* AudioMetadata.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.musikr.metadata
|
|
||||||
|
|
||||||
import android.media.MediaMetadataRetriever
|
|
||||||
import androidx.media3.common.Format
|
|
||||||
|
|
||||||
data class AudioMetadata(
|
|
||||||
val exoPlayerFormat: Format?,
|
|
||||||
val mediaMetadataRetriever: MediaMetadataRetriever
|
|
||||||
)
|
|
|
@ -19,43 +19,24 @@
|
||||||
package org.oxycblt.musikr.metadata
|
package org.oxycblt.musikr.metadata
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.MediaMetadataRetriever
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.exoplayer.MetadataRetriever
|
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.guava.await
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
import org.oxycblt.ktaglib.FileRef
|
||||||
|
import org.oxycblt.ktaglib.KTagLib
|
||||||
|
import org.oxycblt.ktaglib.Metadata
|
||||||
import org.oxycblt.musikr.fs.query.DeviceFile
|
import org.oxycblt.musikr.fs.query.DeviceFile
|
||||||
|
|
||||||
interface MetadataExtractor {
|
interface MetadataExtractor {
|
||||||
suspend fun extract(file: DeviceFile): AudioMetadata
|
suspend fun extract(file: DeviceFile): Metadata?
|
||||||
}
|
}
|
||||||
|
|
||||||
class MetadataExtractorImpl
|
class MetadataExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||||
@Inject
|
MetadataExtractor {
|
||||||
constructor(
|
override suspend fun extract(file: DeviceFile) =
|
||||||
@ApplicationContext private val context: Context,
|
withContext(Dispatchers.IO) {
|
||||||
private val mediaSourceFactory: MediaSource.Factory
|
KTagLib.open(context, FileRef(unlikelyToBeNull(file.path.name), file.uri))
|
||||||
) : MetadataExtractor {
|
|
||||||
override suspend fun extract(file: DeviceFile): AudioMetadata {
|
|
||||||
val exoPlayerMetadataFuture =
|
|
||||||
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(file.uri))
|
|
||||||
val mediaMetadataRetriever =
|
|
||||||
MediaMetadataRetriever().apply {
|
|
||||||
withContext(Dispatchers.IO) { setDataSource(context, file.uri) }
|
|
||||||
}
|
|
||||||
val trackGroupArray = exoPlayerMetadataFuture.await()
|
|
||||||
if (trackGroupArray.isEmpty) {
|
|
||||||
return AudioMetadata(null, mediaMetadataRetriever)
|
|
||||||
}
|
|
||||||
val trackGroup = trackGroupArray.get(0)
|
|
||||||
if (trackGroup.length == 0) {
|
|
||||||
return AudioMetadata(null, mediaMetadataRetriever)
|
|
||||||
}
|
|
||||||
val format = trackGroup.getFormat(0)
|
|
||||||
return AudioMetadata(format, mediaMetadataRetriever)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,198 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* ReusableMetadataRetriever.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.musikr.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.TrackGroupArray
|
|
||||||
import androidx.media3.exoplayer.upstream.Allocator
|
|
||||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
|
||||||
import com.google.common.util.concurrent.SettableFuture
|
|
||||||
import java.util.concurrent.Future
|
|
||||||
import javax.inject.Inject
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
private const val MESSAGE_PREPARE = 0
|
|
||||||
private const val MESSAGE_CONTINUE_LOADING = 1
|
|
||||||
private const val MESSAGE_CHECK_FAILURE = 2
|
|
||||||
private const val MESSAGE_RELEASE = 3
|
|
||||||
private const val CHECK_INTERVAL_MS = 100
|
|
||||||
|
|
||||||
// TODO: Rewrite and re-integrate
|
|
||||||
|
|
||||||
interface MetadataRetrieverExt {
|
|
||||||
fun retrieveMetadata(mediaItem: MediaItem): Future<TrackGroupArray>
|
|
||||||
|
|
||||||
fun retrieve()
|
|
||||||
|
|
||||||
interface Factory {
|
|
||||||
fun create(): MetadataRetrieverExt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReusableMetadataRetrieverImpl
|
|
||||||
@Inject
|
|
||||||
constructor(private val mediaSourceFactory: MediaSource.Factory) :
|
|
||||||
MetadataRetrieverExt, Handler.Callback {
|
|
||||||
private val mediaSourceThread = HandlerThread("Auxio:ChunkedMetadataRetriever:${hashCode()}")
|
|
||||||
private val mediaSourceHandler: HandlerWrapper
|
|
||||||
private var job: MetadataJob? = null
|
|
||||||
|
|
||||||
private data class JobParams(
|
|
||||||
val mediaItem: MediaItem,
|
|
||||||
val future: SettableFuture<TrackGroupArray>
|
|
||||||
)
|
|
||||||
|
|
||||||
private class JobData(
|
|
||||||
val params: JobParams,
|
|
||||||
val mediaSource: MediaSource,
|
|
||||||
var mediaPeriod: MediaPeriod?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private class MetadataJob(val data: JobData, val mediaSourceCaller: MediaSourceCaller)
|
|
||||||
|
|
||||||
init {
|
|
||||||
mediaSourceThread.start()
|
|
||||||
mediaSourceHandler = Clock.DEFAULT.createHandler(mediaSourceThread.looper, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun retrieveMetadata(mediaItem: MediaItem): Future<TrackGroupArray> {
|
|
||||||
val job = job
|
|
||||||
check(job == null || job.data.params.future.isDone) { "Already working on something: $job" }
|
|
||||||
val future = SettableFuture.create<TrackGroupArray>()
|
|
||||||
mediaSourceHandler
|
|
||||||
.obtainMessage(MESSAGE_PREPARE, JobParams(mediaItem, future))
|
|
||||||
.sendToTarget()
|
|
||||||
return future
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun retrieve() {
|
|
||||||
mediaSourceHandler.removeCallbacksAndMessages(null)
|
|
||||||
mediaSourceThread.quit()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleMessage(msg: Message): Boolean {
|
|
||||||
when (msg.what) {
|
|
||||||
MESSAGE_PREPARE -> {
|
|
||||||
val params = msg.obj as JobParams
|
|
||||||
|
|
||||||
val mediaSource = mediaSourceFactory.createMediaSource(params.mediaItem)
|
|
||||||
val data = JobData(params, mediaSource, null)
|
|
||||||
val mediaSourceCaller = MediaSourceCaller(data)
|
|
||||||
mediaSource.prepareSource(
|
|
||||||
mediaSourceCaller, /* mediaTransferListener= */ null, PlayerId.UNSET)
|
|
||||||
job = MetadataJob(data, mediaSourceCaller)
|
|
||||||
|
|
||||||
mediaSourceHandler.sendEmptyMessageDelayed(
|
|
||||||
MESSAGE_CHECK_FAILURE, /* delayMs= */ CHECK_INTERVAL_MS)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
MESSAGE_CONTINUE_LOADING -> {
|
|
||||||
val job = job ?: return true
|
|
||||||
checkNotNull(job.data.mediaPeriod)
|
|
||||||
.continueLoading(LoadingInfo.Builder().setPlaybackPositionUs(0).build())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
MESSAGE_CHECK_FAILURE -> {
|
|
||||||
val job = job ?: return true
|
|
||||||
val mediaPeriod = job.data.mediaPeriod
|
|
||||||
val mediaSource = job.data.mediaSource
|
|
||||||
val mediaSourceCaller = job.mediaSourceCaller
|
|
||||||
try {
|
|
||||||
if (mediaPeriod == null) {
|
|
||||||
mediaSource.maybeThrowSourceInfoRefreshError()
|
|
||||||
} else {
|
|
||||||
mediaPeriod.maybeThrowPrepareError()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e("Failed to extract MediaSource")
|
|
||||||
Timber.e(e.stackTraceToString())
|
|
||||||
mediaPeriod?.let(mediaSource::releasePeriod)
|
|
||||||
mediaSource.releaseSource(mediaSourceCaller)
|
|
||||||
job.data.params.future.setException(e)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
MESSAGE_RELEASE -> {
|
|
||||||
val job = job ?: return true
|
|
||||||
val mediaPeriod = job.data.mediaPeriod
|
|
||||||
val mediaSource = job.data.mediaSource
|
|
||||||
val mediaSourceCaller = job.mediaSourceCaller
|
|
||||||
mediaPeriod?.let { mediaSource.releasePeriod(it) }
|
|
||||||
mediaSource.releaseSource(mediaSourceCaller)
|
|
||||||
this.job = null
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class MediaSourceCaller(private val data: JobData) :
|
|
||||||
MediaSource.MediaSourceCaller {
|
|
||||||
|
|
||||||
private val mediaPeriodCallback: MediaPeriodCallback =
|
|
||||||
MediaPeriodCallback(data.params.future)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
mediaPeriodCreated = true
|
|
||||||
val mediaPeriod =
|
|
||||||
source.createPeriod(
|
|
||||||
MediaSource.MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ 0)),
|
|
||||||
allocator,
|
|
||||||
/* startPositionUs= */ 0)
|
|
||||||
data.mediaPeriod = mediaPeriod
|
|
||||||
mediaPeriod.prepare(mediaPeriodCallback, /* positionUs= */ 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class MediaPeriodCallback(
|
|
||||||
private val future: SettableFuture<TrackGroupArray>
|
|
||||||
) : MediaPeriod.Callback {
|
|
||||||
override fun onPrepared(mediaPeriod: MediaPeriod) {
|
|
||||||
future.set(mediaPeriod.getTrackGroups())
|
|
||||||
mediaSourceHandler.sendEmptyMessage(MESSAGE_RELEASE)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
override fun onContinueLoadingRequested(source: MediaPeriod) {
|
|
||||||
mediaSourceHandler.sendEmptyMessage(MESSAGE_CONTINUE_LOADING)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -26,15 +26,16 @@ import kotlinx.coroutines.flow.buffer
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import org.oxycblt.musikr.Storage
|
import org.oxycblt.musikr.Storage
|
||||||
import org.oxycblt.musikr.cache.CachedSong
|
import org.oxycblt.musikr.cache.CachedSong
|
||||||
import org.oxycblt.musikr.cover.Cover
|
import org.oxycblt.musikr.cover.Cover
|
||||||
import org.oxycblt.musikr.cover.CoverParser
|
|
||||||
import org.oxycblt.musikr.fs.query.DeviceFile
|
import org.oxycblt.musikr.fs.query.DeviceFile
|
||||||
import org.oxycblt.musikr.metadata.MetadataExtractor
|
import org.oxycblt.musikr.metadata.MetadataExtractor
|
||||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||||
import org.oxycblt.musikr.tag.parse.TagParser
|
import org.oxycblt.musikr.tag.parse.TagParser
|
||||||
|
import timber.log.Timber as L
|
||||||
|
|
||||||
interface ExtractStep {
|
interface ExtractStep {
|
||||||
fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic>
|
fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic>
|
||||||
|
@ -42,11 +43,8 @@ interface ExtractStep {
|
||||||
|
|
||||||
class ExtractStepImpl
|
class ExtractStepImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(private val metadataExtractor: MetadataExtractor, private val tagParser: TagParser) :
|
||||||
private val metadataExtractor: MetadataExtractor,
|
ExtractStep {
|
||||||
private val tagParser: TagParser,
|
|
||||||
private val coverParser: CoverParser
|
|
||||||
) : ExtractStep {
|
|
||||||
override fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
|
override fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
|
||||||
val cacheResults =
|
val cacheResults =
|
||||||
nodes
|
nodes
|
||||||
|
@ -63,15 +61,16 @@ constructor(
|
||||||
ExtractedMusic.Song(it.file, song.parsedTags, song.cover)
|
ExtractedMusic.Song(it.file, song.parsedTags, song.cover)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val split = uncachedSongs.distribute(8)
|
val split = uncachedSongs.distribute(16)
|
||||||
val extractedSongs =
|
val extractedSongs =
|
||||||
Array(split.hot.size) { i ->
|
Array(split.hot.size) { i ->
|
||||||
split.hot[i]
|
split.hot[i]
|
||||||
.map { node ->
|
.mapNotNull { node ->
|
||||||
val metadata = metadataExtractor.extract(node.file)
|
val metadata =
|
||||||
|
metadataExtractor.extract(node.file) ?: return@mapNotNull null
|
||||||
|
L.d("Extracted tags for ${metadata.id3v2}")
|
||||||
val tags = tagParser.parse(node.file, metadata)
|
val tags = tagParser.parse(node.file, metadata)
|
||||||
val coverData = coverParser.extract(metadata)
|
val cover = metadata.cover?.let { storage.storedCovers.write(it) }
|
||||||
val cover = coverData?.let { storage.storedCovers.write(it) }
|
|
||||||
ExtractedMusic.Song(node.file, tags, cover)
|
ExtractedMusic.Song(node.file, tags, cover)
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
|
|
|
@ -20,37 +20,38 @@ package org.oxycblt.musikr.tag.parse
|
||||||
|
|
||||||
import androidx.core.text.isDigitsOnly
|
import androidx.core.text.isDigitsOnly
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
import org.oxycblt.ktaglib.Metadata
|
||||||
import org.oxycblt.musikr.tag.Date
|
import org.oxycblt.musikr.tag.Date
|
||||||
import org.oxycblt.musikr.tag.util.parseId3v2PositionField
|
import org.oxycblt.musikr.tag.util.parseId3v2PositionField
|
||||||
import org.oxycblt.musikr.tag.util.parseVorbisPositionField
|
import org.oxycblt.musikr.tag.util.parseXiphPositionField
|
||||||
|
|
||||||
// Song
|
// Song
|
||||||
fun ExoPlayerTags.musicBrainzId() =
|
fun Metadata.musicBrainzId() =
|
||||||
(vorbis["musicbrainz_releasetrackid"]
|
(xiph["musicbrainz_releasetrackid"]
|
||||||
?: vorbis["musicbrainz release track id"]
|
?: xiph["musicbrainz release track id"]
|
||||||
?: id3v2["TXXX:musicbrainz release track id"]
|
?: id3v2["TXXX:musicbrainz release track id"]
|
||||||
?: id3v2["TXXX:musicbrainz_releasetrackid"])
|
?: id3v2["TXXX:musicbrainz_releasetrackid"])
|
||||||
?.first()
|
?.first()
|
||||||
|
|
||||||
fun ExoPlayerTags.name() = (vorbis["title"] ?: id3v2["TIT2"])?.first()
|
fun Metadata.name() = (xiph["title"] ?: id3v2["TIT2"])?.first()
|
||||||
|
|
||||||
fun ExoPlayerTags.sortName() = (vorbis["titlesort"] ?: id3v2["TSOT"])?.first()
|
fun Metadata.sortName() = (xiph["titlesort"] ?: id3v2["TSOT"])?.first()
|
||||||
|
|
||||||
// Track.
|
// Track.
|
||||||
fun ExoPlayerTags.track() =
|
fun Metadata.track() =
|
||||||
(parseVorbisPositionField(
|
(parseXiphPositionField(
|
||||||
vorbis["tracknumber"]?.first(),
|
xiph["tracknumber"]?.first(),
|
||||||
(vorbis["totaltracks"] ?: vorbis["tracktotal"] ?: vorbis["trackc"])?.first())
|
(xiph["totaltracks"] ?: xiph["tracktotal"] ?: xiph["trackc"])?.first())
|
||||||
?: id3v2["TRCK"]?.run { first().parseId3v2PositionField() })
|
?: id3v2["TRCK"]?.run { first().parseId3v2PositionField() })
|
||||||
|
|
||||||
// Disc and it's subtitle name.
|
// Disc and it's subtitle name.
|
||||||
fun ExoPlayerTags.disc() =
|
fun Metadata.disc() =
|
||||||
(parseVorbisPositionField(
|
(parseXiphPositionField(
|
||||||
vorbis["discnumber"]?.first(),
|
xiph["discnumber"]?.first(),
|
||||||
(vorbis["totaldiscs"] ?: vorbis["disctotal"] ?: vorbis["discc"])?.run { first() })
|
(xiph["totaldiscs"] ?: xiph["disctotal"] ?: xiph["discc"])?.run { first() })
|
||||||
?: id3v2["TPOS"]?.run { first().parseId3v2PositionField() })
|
?: id3v2["TPOS"]?.run { first().parseId3v2PositionField() })
|
||||||
|
|
||||||
fun ExoPlayerTags.subtitle() = (vorbis["discsubtitle"] ?: id3v2["TSST"])?.first()
|
fun Metadata.subtitle() = (xiph["discsubtitle"] ?: id3v2["TSST"])?.first()
|
||||||
|
|
||||||
// Dates are somewhat complicated, as not only did their semantics change from a flat year
|
// 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
|
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
|
||||||
|
@ -64,17 +65,17 @@ fun ExoPlayerTags.subtitle() = (vorbis["discsubtitle"] ?: id3v2["TSST"])?.first(
|
||||||
// TODO: Show original and normal dates side-by-side
|
// TODO: Show original and normal dates side-by-side
|
||||||
// TODO: Handle dates that are in "January" because the actual specific release date
|
// TODO: Handle dates that are in "January" because the actual specific release date
|
||||||
// isn't known?
|
// isn't known?
|
||||||
fun ExoPlayerTags.date() =
|
fun Metadata.date() =
|
||||||
(vorbis["originaldate"]?.run { Date.from(first()) }
|
(xiph["originaldate"]?.run { Date.from(first()) }
|
||||||
?: vorbis["date"]?.run { Date.from(first()) }
|
?: xiph["date"]?.run { Date.from(first()) }
|
||||||
?: vorbis["year"]?.run { Date.from(first()) }
|
?: xiph["year"]?.run { Date.from(first()) }
|
||||||
?:
|
?:
|
||||||
|
|
||||||
// Vorbis dates are less complicated, but there are still several types
|
// xiph dates are less complicated, but there are still several types
|
||||||
// Our hierarchy for dates is as such:
|
// Our hierarchy for dates is as such:
|
||||||
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
|
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
|
||||||
// 2. Date, as it is the most common date type
|
// 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
|
// 3. Year, as old xiph 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!)
|
// date tag that android supports, so it must be 15 years old or more!)
|
||||||
id3v2["TDOR"]?.run { Date.from(first()) }
|
id3v2["TDOR"]?.run { Date.from(first()) }
|
||||||
?: id3v2["TDRC"]?.run { Date.from(first()) }
|
?: id3v2["TDRC"]?.run { Date.from(first()) }
|
||||||
|
@ -82,20 +83,20 @@ fun ExoPlayerTags.date() =
|
||||||
?: parseId3v23Date())
|
?: parseId3v23Date())
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
fun ExoPlayerTags.albumMusicBrainzId() =
|
fun Metadata.albumMusicBrainzId() =
|
||||||
(vorbis["musicbrainz_albumid"]
|
(xiph["musicbrainz_albumid"]
|
||||||
?: vorbis["musicbrainz album id"]
|
?: xiph["musicbrainz album id"]
|
||||||
?: id3v2["TXXX:musicbrainz album id"]
|
?: id3v2["TXXX:musicbrainz album id"]
|
||||||
?: id3v2["TXXX:musicbrainz_albumid"])
|
?: id3v2["TXXX:musicbrainz_albumid"])
|
||||||
?.first()
|
?.first()
|
||||||
|
|
||||||
fun ExoPlayerTags.albumName() = (vorbis["album"] ?: id3v2["TALB"])?.first()
|
fun Metadata.albumName() = (xiph["album"] ?: id3v2["TALB"])?.first()
|
||||||
|
|
||||||
fun ExoPlayerTags.albumSortName() = (vorbis["albumsort"] ?: id3v2["TSOA"])?.first()
|
fun Metadata.albumSortName() = (xiph["albumsort"] ?: id3v2["TSOA"])?.first()
|
||||||
|
|
||||||
fun ExoPlayerTags.releaseTypes() =
|
fun Metadata.releaseTypes() =
|
||||||
(vorbis["releasetype"]
|
(xiph["releasetype"]
|
||||||
?: vorbis["musicbrainz album type"]
|
?: xiph["musicbrainz album type"]
|
||||||
?: id3v2["TXXX:musicbrainz album type"]
|
?: id3v2["TXXX:musicbrainz album type"]
|
||||||
?: id3v2["TXXX:releasetype"]
|
?: id3v2["TXXX:releasetype"]
|
||||||
?:
|
?:
|
||||||
|
@ -103,25 +104,25 @@ fun ExoPlayerTags.releaseTypes() =
|
||||||
id3v2["GRP1"])
|
id3v2["GRP1"])
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
fun ExoPlayerTags.artistMusicBrainzIds() =
|
fun Metadata.artistMusicBrainzIds() =
|
||||||
(vorbis["musicbrainz_artistid"]
|
(xiph["musicbrainz_artistid"]
|
||||||
?: vorbis["musicbrainz artist id"]
|
?: xiph["musicbrainz artist id"]
|
||||||
?: id3v2["TXXX:musicbrainz artist id"]
|
?: id3v2["TXXX:musicbrainz artist id"]
|
||||||
?: id3v2["TXXX:musicbrainz_artistid"])
|
?: id3v2["TXXX:musicbrainz_artistid"])
|
||||||
|
|
||||||
fun ExoPlayerTags.artistNames() =
|
fun Metadata.artistNames() =
|
||||||
(vorbis["artists"]
|
(xiph["artists"]
|
||||||
?: vorbis["artist"]
|
?: xiph["artist"]
|
||||||
?: id3v2["TXXX:artists"]
|
?: id3v2["TXXX:artists"]
|
||||||
?: id3v2["TPE1"]
|
?: id3v2["TPE1"]
|
||||||
?: id3v2["TXXX:artist"])
|
?: id3v2["TXXX:artist"])
|
||||||
|
|
||||||
fun ExoPlayerTags.artistSortNames() =
|
fun Metadata.artistSortNames() =
|
||||||
(vorbis["artistssort"]
|
(xiph["artistssort"]
|
||||||
?: vorbis["artists_sort"]
|
?: xiph["artists_sort"]
|
||||||
?: vorbis["artists sort"]
|
?: xiph["artists sort"]
|
||||||
?: vorbis["artistsort"]
|
?: xiph["artistsort"]
|
||||||
?: vorbis["artist sort"]
|
?: xiph["artist sort"]
|
||||||
?: id3v2["TXXX:artistssort"]
|
?: id3v2["TXXX:artistssort"]
|
||||||
?: id3v2["TXXX:artists_sort"]
|
?: id3v2["TXXX:artists_sort"]
|
||||||
?: id3v2["TXXX:artists sort"]
|
?: id3v2["TXXX:artists sort"]
|
||||||
|
@ -129,18 +130,18 @@ fun ExoPlayerTags.artistSortNames() =
|
||||||
?: id3v2["artistsort"]
|
?: id3v2["artistsort"]
|
||||||
?: id3v2["TXXX:artist sort"])
|
?: id3v2["TXXX:artist sort"])
|
||||||
|
|
||||||
fun ExoPlayerTags.albumArtistMusicBrainzIds() =
|
fun Metadata.albumArtistMusicBrainzIds() =
|
||||||
(vorbis["musicbrainz_albumartistid"]
|
(xiph["musicbrainz_albumartistid"]
|
||||||
?: vorbis["musicbrainz album artist id"]
|
?: xiph["musicbrainz album artist id"]
|
||||||
?: id3v2["TXXX:musicbrainz album artist id"]
|
?: id3v2["TXXX:musicbrainz album artist id"]
|
||||||
?: id3v2["TXXX:musicbrainz_albumartistid"])
|
?: id3v2["TXXX:musicbrainz_albumartistid"])
|
||||||
|
|
||||||
fun ExoPlayerTags.albumArtistNames() =
|
fun Metadata.albumArtistNames() =
|
||||||
(vorbis["albumartists"]
|
(xiph["albumartists"]
|
||||||
?: vorbis["album_artists"]
|
?: xiph["album_artists"]
|
||||||
?: vorbis["album artists"]
|
?: xiph["album artists"]
|
||||||
?: vorbis["albumartist"]
|
?: xiph["albumartist"]
|
||||||
?: vorbis["album artist"]
|
?: xiph["album artist"]
|
||||||
?: id3v2["TXXX:albumartists"]
|
?: id3v2["TXXX:albumartists"]
|
||||||
?: id3v2["TXXX:album_artists"]
|
?: id3v2["TXXX:album_artists"]
|
||||||
?: id3v2["TXXX:album artists"]
|
?: id3v2["TXXX:album artists"]
|
||||||
|
@ -148,12 +149,12 @@ fun ExoPlayerTags.albumArtistNames() =
|
||||||
?: id3v2["TXXX:albumartist"]
|
?: id3v2["TXXX:albumartist"]
|
||||||
?: id3v2["TXXX:album artist"])
|
?: id3v2["TXXX:album artist"])
|
||||||
|
|
||||||
fun ExoPlayerTags.albumArtistSortNames() =
|
fun Metadata.albumArtistSortNames() =
|
||||||
(vorbis["albumartistssort"]
|
(xiph["albumartistssort"]
|
||||||
?: vorbis["albumartists_sort"]
|
?: xiph["albumartists_sort"]
|
||||||
?: vorbis["albumartists sort"]
|
?: xiph["albumartists sort"]
|
||||||
?: vorbis["albumartistsort"]
|
?: xiph["albumartistsort"]
|
||||||
?: vorbis["album artist sort"]
|
?: xiph["album artist sort"]
|
||||||
?: id3v2["TXXX:albumartistssort"]
|
?: id3v2["TXXX:albumartistssort"]
|
||||||
?: id3v2["TXXX:albumartists_sort"]
|
?: id3v2["TXXX:albumartists_sort"]
|
||||||
?: id3v2["TXXX:albumartists sort"]
|
?: id3v2["TXXX:albumartists sort"]
|
||||||
|
@ -163,12 +164,12 @@ fun ExoPlayerTags.albumArtistSortNames() =
|
||||||
?: id3v2["TXXX:album artist sort"])
|
?: id3v2["TXXX:album artist sort"])
|
||||||
|
|
||||||
// Genre
|
// Genre
|
||||||
fun ExoPlayerTags.genreNames() = vorbis["genre"] ?: id3v2["TCON"]
|
fun Metadata.genreNames() = xiph["genre"] ?: id3v2["TCON"]
|
||||||
|
|
||||||
// Compilation Flag
|
// Compilation Flag
|
||||||
fun ExoPlayerTags.isCompilation() =
|
fun Metadata.isCompilation() =
|
||||||
(vorbis["compilation"]
|
(xiph["compilation"]
|
||||||
?: vorbis["itunescompilation"]
|
?: xiph["itunescompilation"]
|
||||||
?: id3v2["TCMP"] // This is a non-standard itunes extension
|
?: id3v2["TCMP"] // This is a non-standard itunes extension
|
||||||
?: id3v2["TXXX:compilation"]
|
?: id3v2["TXXX:compilation"]
|
||||||
?: id3v2["TXXX:itunescompilation"])
|
?: id3v2["TXXX:itunescompilation"])
|
||||||
|
@ -178,17 +179,17 @@ fun ExoPlayerTags.isCompilation() =
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplayGain information
|
// ReplayGain information
|
||||||
fun ExoPlayerTags.replayGainTrackAdjustment() =
|
fun Metadata.replayGainTrackAdjustment() =
|
||||||
(vorbis["r128_track_gain"]?.parseR128Adjustment()
|
(xiph["r128_track_gain"]?.parseR128Adjustment()
|
||||||
?: vorbis["replaygain_track_gain"]?.parseReplayGainAdjustment()
|
?: xiph["replaygain_track_gain"]?.parseReplayGainAdjustment()
|
||||||
?: id3v2["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment())
|
?: id3v2["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment())
|
||||||
|
|
||||||
fun ExoPlayerTags.replayGainAlbumAdjustment() =
|
fun Metadata.replayGainAlbumAdjustment() =
|
||||||
(vorbis["r128_album_gain"]?.parseR128Adjustment()
|
(xiph["r128_album_gain"]?.parseR128Adjustment()
|
||||||
?: vorbis["replaygain_album_gain"]?.parseReplayGainAdjustment()
|
?: xiph["replaygain_album_gain"]?.parseReplayGainAdjustment()
|
||||||
?: id3v2["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment())
|
?: id3v2["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment())
|
||||||
|
|
||||||
private fun ExoPlayerTags.parseId3v23Date(): Date? {
|
private fun Metadata.parseId3v23Date(): Date? {
|
||||||
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||||
// is present.
|
// is present.
|
||||||
val year =
|
val year =
|
||||||
|
|
|
@ -1,94 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* ExoPlayerTags.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.musikr.tag.parse
|
|
||||||
|
|
||||||
import androidx.media3.common.Metadata
|
|
||||||
import androidx.media3.extractor.metadata.id3.InternalFrame
|
|
||||||
import androidx.media3.extractor.metadata.id3.TextInformationFrame
|
|
||||||
import androidx.media3.extractor.metadata.vorbis.VorbisComment
|
|
||||||
import org.oxycblt.musikr.tag.util.correctWhitespace
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags.
|
|
||||||
*
|
|
||||||
* @param metadata The [Metadata] to wrap.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class ExoPlayerTags(metadata: Metadata) {
|
|
||||||
private val _id3v2 = mutableMapOf<String, MutableList<String>>()
|
|
||||||
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
|
|
||||||
val id3v2: Map<String, List<String>>
|
|
||||||
get() = _id3v2
|
|
||||||
|
|
||||||
private val _vorbis = mutableMapOf<String, MutableList<String>>()
|
|
||||||
/** The vorbis comments found in the file. Can have more than one value. */
|
|
||||||
val vorbis: Map<String, List<String>>
|
|
||||||
get() = _vorbis
|
|
||||||
|
|
||||||
init {
|
|
||||||
for (i in 0 until metadata.length()) {
|
|
||||||
when (val tag = metadata[i]) {
|
|
||||||
is TextInformationFrame -> {
|
|
||||||
// Map TXXX frames differently so we can specifically index by their
|
|
||||||
// descriptions.
|
|
||||||
val id =
|
|
||||||
tag.description?.let { "TXXX:${it.sanitize().lowercase()}" }
|
|
||||||
?: tag.id.sanitize()
|
|
||||||
val values = tag.values.map { it.sanitize() }.correctWhitespace()
|
|
||||||
if (values.isNotEmpty()) {
|
|
||||||
// Normally, duplicate ID3v2 frames are forbidden. But since MP4 atoms,
|
|
||||||
// which can also have duplicates, are mapped to ID3v2 frames by ExoPlayer,
|
|
||||||
// we must drop this invariant and gracefully treat duplicates as if they
|
|
||||||
// are another way of specfiying multi-value tags.
|
|
||||||
_id3v2.getOrPut(id) { mutableListOf() }.addAll(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is InternalFrame -> {
|
|
||||||
// Most MP4 metadata atoms map to ID3v2 text frames, except for the ---- atom,
|
|
||||||
// which has it's own frame. Map this to TXXX, it's rough ID3v2 equivalent.
|
|
||||||
val id = "TXXX:${tag.description.sanitize().lowercase()}"
|
|
||||||
val value = tag.text
|
|
||||||
if (value.isNotEmpty()) {
|
|
||||||
_id3v2.getOrPut(id) { mutableListOf() }.add(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is VorbisComment -> {
|
|
||||||
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
|
||||||
val id = tag.key.sanitize().lowercase()
|
|
||||||
if (id == "metadata_block_picture") {
|
|
||||||
// Picture, we don't care about these
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val value = tag.value.sanitize().correctWhitespace()
|
|
||||||
if (value != null) {
|
|
||||||
_vorbis.getOrPut(id) { mutableListOf() }.add(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies and sanitizes a possibly invalid string outputted from ExoPlayer.
|
|
||||||
*
|
|
||||||
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
|
|
||||||
* the Unicode replacement byte sequence.
|
|
||||||
*/
|
|
||||||
private fun String.sanitize() = String(encodeToByteArray())
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* MediaMetadataTagFields.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.musikr.tag.parse
|
|
||||||
|
|
||||||
import android.media.MediaMetadataRetriever
|
|
||||||
|
|
||||||
fun MediaMetadataRetriever.durationMs() =
|
|
||||||
extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
|
|
|
@ -19,48 +19,38 @@
|
||||||
package org.oxycblt.musikr.tag.parse
|
package org.oxycblt.musikr.tag.parse
|
||||||
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import org.oxycblt.ktaglib.Metadata
|
||||||
import org.oxycblt.musikr.fs.query.DeviceFile
|
import org.oxycblt.musikr.fs.query.DeviceFile
|
||||||
import org.oxycblt.musikr.metadata.AudioMetadata
|
|
||||||
|
|
||||||
interface TagParser {
|
interface TagParser {
|
||||||
fun parse(file: DeviceFile, metadata: AudioMetadata): ParsedTags
|
fun parse(file: DeviceFile, metadata: Metadata): ParsedTags
|
||||||
}
|
}
|
||||||
|
|
||||||
class MissingTagError(what: String) : Error("missing tag: $what")
|
class MissingTagError(what: String) : Error("missing tag: $what")
|
||||||
|
|
||||||
class TagParserImpl @Inject constructor() : TagParser {
|
class TagParserImpl @Inject constructor() : TagParser {
|
||||||
override fun parse(file: DeviceFile, metadata: AudioMetadata): ParsedTags {
|
override fun parse(file: DeviceFile, metadata: Metadata): ParsedTags {
|
||||||
val exoPlayerMetadata =
|
|
||||||
metadata.exoPlayerFormat?.metadata
|
|
||||||
?: return ParsedTags(
|
|
||||||
durationMs =
|
|
||||||
metadata.mediaMetadataRetriever.durationMs()
|
|
||||||
?: throw MissingTagError("durationMs"),
|
|
||||||
name = file.path.name ?: throw MissingTagError("name"),
|
|
||||||
)
|
|
||||||
val exoPlayerTags = ExoPlayerTags(exoPlayerMetadata)
|
|
||||||
return ParsedTags(
|
return ParsedTags(
|
||||||
durationMs =
|
durationMs = metadata.properties.durationMs,
|
||||||
metadata.mediaMetadataRetriever.durationMs() ?: throw MissingTagError("durationMs"),
|
replayGainTrackAdjustment = metadata.replayGainTrackAdjustment(),
|
||||||
replayGainTrackAdjustment = exoPlayerTags.replayGainTrackAdjustment(),
|
replayGainAlbumAdjustment = metadata.replayGainAlbumAdjustment(),
|
||||||
replayGainAlbumAdjustment = exoPlayerTags.replayGainAlbumAdjustment(),
|
musicBrainzId = metadata.musicBrainzId(),
|
||||||
musicBrainzId = exoPlayerTags.musicBrainzId(),
|
name = metadata.name() ?: file.path.name ?: throw MissingTagError("name"),
|
||||||
name = exoPlayerTags.name() ?: file.path.name ?: throw MissingTagError("name"),
|
sortName = metadata.sortName(),
|
||||||
sortName = exoPlayerTags.sortName(),
|
track = metadata.track(),
|
||||||
track = exoPlayerTags.track(),
|
disc = metadata.disc(),
|
||||||
disc = exoPlayerTags.disc(),
|
subtitle = metadata.subtitle(),
|
||||||
subtitle = exoPlayerTags.subtitle(),
|
date = metadata.date(),
|
||||||
date = exoPlayerTags.date(),
|
albumMusicBrainzId = metadata.albumMusicBrainzId(),
|
||||||
albumMusicBrainzId = exoPlayerTags.albumMusicBrainzId(),
|
albumName = metadata.albumName(),
|
||||||
albumName = exoPlayerTags.albumName(),
|
albumSortName = metadata.albumSortName(),
|
||||||
albumSortName = exoPlayerTags.albumSortName(),
|
releaseTypes = metadata.releaseTypes() ?: listOf(),
|
||||||
releaseTypes = exoPlayerTags.releaseTypes() ?: listOf(),
|
artistMusicBrainzIds = metadata.artistMusicBrainzIds() ?: listOf(),
|
||||||
artistMusicBrainzIds = exoPlayerTags.artistMusicBrainzIds() ?: listOf(),
|
artistNames = metadata.artistNames() ?: listOf(),
|
||||||
artistNames = exoPlayerTags.artistNames() ?: listOf(),
|
artistSortNames = metadata.artistSortNames() ?: listOf(),
|
||||||
artistSortNames = exoPlayerTags.artistSortNames() ?: listOf(),
|
albumArtistMusicBrainzIds = metadata.albumArtistMusicBrainzIds() ?: listOf(),
|
||||||
albumArtistMusicBrainzIds = exoPlayerTags.albumArtistMusicBrainzIds() ?: listOf(),
|
albumArtistNames = metadata.albumArtistNames() ?: listOf(),
|
||||||
albumArtistNames = exoPlayerTags.albumArtistNames() ?: listOf(),
|
albumArtistSortNames = metadata.albumArtistSortNames() ?: listOf(),
|
||||||
albumArtistSortNames = exoPlayerTags.albumArtistSortNames() ?: listOf(),
|
genreNames = metadata.genreNames() ?: listOf())
|
||||||
genreNames = exoPlayerTags.genreNames() ?: listOf())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,7 +47,7 @@ fun String.parseId3v2PositionField() =
|
||||||
*
|
*
|
||||||
* @see transformPositionField
|
* @see transformPositionField
|
||||||
*/
|
*/
|
||||||
fun parseVorbisPositionField(pos: String?, total: String?) =
|
fun parseXiphPositionField(pos: String?, total: String?) =
|
||||||
transformPositionField(pos?.toIntOrNull(), total?.toIntOrNull())
|
transformPositionField(pos?.toIntOrNull(), total?.toIntOrNull())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
<string name="set_key_square_covers" translatable="false">auxio_square_covers</string>
|
<string name="set_key_square_covers" translatable="false">auxio_square_covers</string>
|
||||||
<string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string>
|
<string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string>
|
||||||
<string name="set_key_exclude_non_music" translatable="false">auxio_exclude_non_music</string>
|
<string name="set_key_exclude_non_music" translatable="false">auxio_exclude_non_music</string>
|
||||||
<string name="set_key_music_locations" translatable="false">auxio_music_locations</string>
|
<string name="set_key_music_locations" translatable="false">auxio_music_locations2</string>
|
||||||
<string name="set_key_separators" translatable="false">auxio_separators</string>
|
<string name="set_key_separators" translatable="false">auxio_separators</string>
|
||||||
<string name="set_key_auto_sort_names" translatable="false">auxio_auto_sort_names</string>
|
<string name="set_key_auto_sort_names" translatable="false">auxio_auto_sort_names</string>
|
||||||
|
|
||||||
|
|
|
@ -1,128 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* TextTagsTest.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.musikr.tag.parse
|
|
||||||
|
|
||||||
import androidx.media3.common.Metadata
|
|
||||||
import androidx.media3.extractor.metadata.flac.PictureFrame
|
|
||||||
import androidx.media3.extractor.metadata.id3.ApicFrame
|
|
||||||
import androidx.media3.extractor.metadata.id3.InternalFrame
|
|
||||||
import androidx.media3.extractor.metadata.id3.TextInformationFrame
|
|
||||||
import androidx.media3.extractor.metadata.vorbis.VorbisComment
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class TextTagsTest {
|
|
||||||
@Test
|
|
||||||
fun textTags_vorbis() {
|
|
||||||
val exoPlayerTags = ExoPlayerTags(VORBIS_METADATA)
|
|
||||||
assertTrue(exoPlayerTags.id3v2.isEmpty())
|
|
||||||
assertEquals(listOf("Wheel"), exoPlayerTags.vorbis["title"])
|
|
||||||
assertEquals(listOf("Paraglow"), exoPlayerTags.vorbis["album"])
|
|
||||||
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.vorbis["artist"])
|
|
||||||
assertEquals(listOf("2022"), exoPlayerTags.vorbis["date"])
|
|
||||||
assertEquals(listOf("ep"), exoPlayerTags.vorbis["releasetype"])
|
|
||||||
assertEquals(listOf("+2 dB"), exoPlayerTags.vorbis["replaygain_track_gain"])
|
|
||||||
assertEquals(null, exoPlayerTags.id3v2["APIC"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun textTags_id3v2() {
|
|
||||||
val exoPlayerTags = ExoPlayerTags(ID3V2_METADATA)
|
|
||||||
assertTrue(exoPlayerTags.vorbis.isEmpty())
|
|
||||||
assertEquals(listOf("Wheel"), exoPlayerTags.id3v2["TIT2"])
|
|
||||||
assertEquals(listOf("Paraglow"), exoPlayerTags.id3v2["TALB"])
|
|
||||||
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.id3v2["TPE1"])
|
|
||||||
assertEquals(listOf("2022"), exoPlayerTags.id3v2["TDRC"])
|
|
||||||
assertEquals(listOf("ep"), exoPlayerTags.id3v2["TXXX:musicbrainz album type"])
|
|
||||||
assertEquals(listOf("+2 dB"), exoPlayerTags.id3v2["TXXX:replaygain_track_gain"])
|
|
||||||
assertEquals(null, exoPlayerTags.id3v2["metadata_block_picture"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun textTags_mp4() {
|
|
||||||
val exoPlayerTags = ExoPlayerTags(MP4_METADATA)
|
|
||||||
assertTrue(exoPlayerTags.vorbis.isEmpty())
|
|
||||||
assertEquals(listOf("Wheel"), exoPlayerTags.id3v2["TIT2"])
|
|
||||||
assertEquals(listOf("Paraglow"), exoPlayerTags.id3v2["TALB"])
|
|
||||||
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.id3v2["TPE1"])
|
|
||||||
assertEquals(listOf("2022"), exoPlayerTags.id3v2["TDRC"])
|
|
||||||
assertEquals(listOf("ep"), exoPlayerTags.id3v2["TXXX:musicbrainz album type"])
|
|
||||||
assertEquals(listOf("+2 dB"), exoPlayerTags.id3v2["TXXX:replaygain_track_gain"])
|
|
||||||
assertEquals(null, exoPlayerTags.id3v2["metadata_block_picture"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun textTags_id3v2_vorbis_combined() {
|
|
||||||
val exoPlayerTags =
|
|
||||||
ExoPlayerTags(VORBIS_METADATA.copyWithAppendedEntriesFrom(ID3V2_METADATA))
|
|
||||||
assertEquals(listOf("Wheel"), exoPlayerTags.vorbis["title"])
|
|
||||||
assertEquals(listOf("Paraglow"), exoPlayerTags.vorbis["album"])
|
|
||||||
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.vorbis["artist"])
|
|
||||||
assertEquals(listOf("2022"), exoPlayerTags.vorbis["date"])
|
|
||||||
assertEquals(listOf("ep"), exoPlayerTags.vorbis["releasetype"])
|
|
||||||
assertEquals(listOf("+2 dB"), exoPlayerTags.vorbis["replaygain_track_gain"])
|
|
||||||
assertEquals(null, exoPlayerTags.id3v2["metadata_block_picture"])
|
|
||||||
|
|
||||||
assertEquals(listOf("Wheel"), exoPlayerTags.id3v2["TIT2"])
|
|
||||||
assertEquals(listOf("Paraglow"), exoPlayerTags.id3v2["TALB"])
|
|
||||||
assertEquals(listOf("Parannoul", "Asian Glow"), exoPlayerTags.id3v2["TPE1"])
|
|
||||||
assertEquals(listOf("2022"), exoPlayerTags.id3v2["TDRC"])
|
|
||||||
assertEquals(null, exoPlayerTags.id3v2["APIC"])
|
|
||||||
assertEquals(listOf("ep"), exoPlayerTags.id3v2["TXXX:musicbrainz album type"])
|
|
||||||
assertEquals(listOf("+2 dB"), exoPlayerTags.id3v2["TXXX:replaygain_track_gain"])
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val VORBIS_METADATA =
|
|
||||||
Metadata(
|
|
||||||
VorbisComment("TITLE", "Wheel"),
|
|
||||||
VorbisComment("ALBUM", "Paraglow"),
|
|
||||||
VorbisComment("ARTIST", "Parannoul"),
|
|
||||||
VorbisComment("ARTIST", "Asian Glow"),
|
|
||||||
VorbisComment("DATE", "2022"),
|
|
||||||
VorbisComment("RELEASETYPE", "ep"),
|
|
||||||
VorbisComment("METADATA_BLOCK_PICTURE", ""),
|
|
||||||
VorbisComment("REPLAYGAIN_TRACK_GAIN", "+2 dB"),
|
|
||||||
PictureFrame(0, "", "", 0, 0, 0, 0, byteArrayOf()))
|
|
||||||
|
|
||||||
private val ID3V2_METADATA =
|
|
||||||
Metadata(
|
|
||||||
TextInformationFrame("TIT2", null, listOf("Wheel")),
|
|
||||||
TextInformationFrame("TALB", null, listOf("Paraglow")),
|
|
||||||
TextInformationFrame("TPE1", null, listOf("Parannoul", "Asian Glow")),
|
|
||||||
TextInformationFrame("TDRC", null, listOf("2022")),
|
|
||||||
TextInformationFrame("TXXX", "MusicBrainz Album Type", listOf("ep")),
|
|
||||||
TextInformationFrame("TXXX", "replaygain_track_gain", listOf("+2 dB")),
|
|
||||||
ApicFrame("", "", 0, byteArrayOf()))
|
|
||||||
|
|
||||||
// MP4 atoms are mapped to ID3v2 text information frames by ExoPlayer, but can
|
|
||||||
// duplicate frames and have ---- mapped to InternalFrame.
|
|
||||||
private val MP4_METADATA =
|
|
||||||
Metadata(
|
|
||||||
TextInformationFrame("TIT2", null, listOf("Wheel")),
|
|
||||||
TextInformationFrame("TALB", null, listOf("Paraglow")),
|
|
||||||
TextInformationFrame("TPE1", null, listOf("Parannoul")),
|
|
||||||
TextInformationFrame("TPE1", null, listOf("Asian Glow")),
|
|
||||||
TextInformationFrame("TDRC", null, listOf("2022")),
|
|
||||||
TextInformationFrame("TXXX", "MusicBrainz Album Type", listOf("ep")),
|
|
||||||
InternalFrame("com.apple.iTunes", "replaygain_track_gain", "+2 dB"),
|
|
||||||
ApicFrame("", "", 0, byteArrayOf()))
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue