diff --git a/app/src/main/java/org/oxycblt/musikr/cover/CoverModule.kt b/app/src/main/java/org/oxycblt/musikr/cover/CoverModule.kt deleted file mode 100644 index 976a3233f..000000000 --- a/app/src/main/java/org/oxycblt/musikr/cover/CoverModule.kt +++ /dev/null @@ -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 . - */ - -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 -} diff --git a/app/src/main/java/org/oxycblt/musikr/cover/CoverParser.kt b/app/src/main/java/org/oxycblt/musikr/cover/CoverParser.kt deleted file mode 100644 index 652f910cc..000000000 --- a/app/src/main/java/org/oxycblt/musikr/cover/CoverParser.kt +++ /dev/null @@ -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 . - */ - -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 - } -} diff --git a/app/src/main/java/org/oxycblt/musikr/metadata/AudioMetadata.kt b/app/src/main/java/org/oxycblt/musikr/metadata/AudioMetadata.kt deleted file mode 100644 index 07ce984d2..000000000 --- a/app/src/main/java/org/oxycblt/musikr/metadata/AudioMetadata.kt +++ /dev/null @@ -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 . - */ - -package org.oxycblt.musikr.metadata - -import android.media.MediaMetadataRetriever -import androidx.media3.common.Format - -data class AudioMetadata( - val exoPlayerFormat: Format?, - val mediaMetadataRetriever: MediaMetadataRetriever -) diff --git a/app/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt index a5fe97f71..4bce9ff12 100644 --- a/app/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt @@ -19,43 +19,24 @@ package org.oxycblt.musikr.metadata 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 javax.inject.Inject import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.guava.await 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 interface MetadataExtractor { - suspend fun extract(file: DeviceFile): AudioMetadata + suspend fun extract(file: DeviceFile): Metadata? } -class MetadataExtractorImpl -@Inject -constructor( - @ApplicationContext private val context: Context, - private val mediaSourceFactory: MediaSource.Factory -) : 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) +class MetadataExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) : + MetadataExtractor { + override suspend fun extract(file: DeviceFile) = + withContext(Dispatchers.IO) { + KTagLib.open(context, FileRef(unlikelyToBeNull(file.path.name), file.uri)) } - val trackGroup = trackGroupArray.get(0) - if (trackGroup.length == 0) { - return AudioMetadata(null, mediaMetadataRetriever) - } - val format = trackGroup.getFormat(0) - return AudioMetadata(format, mediaMetadataRetriever) - } } diff --git a/app/src/main/java/org/oxycblt/musikr/metadata/ReusableMetadataRetriever.kt b/app/src/main/java/org/oxycblt/musikr/metadata/ReusableMetadataRetriever.kt deleted file mode 100644 index 27f844156..000000000 --- a/app/src/main/java/org/oxycblt/musikr/metadata/ReusableMetadataRetriever.kt +++ /dev/null @@ -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 . - */ - -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 - - 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 - ) - - 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 { - val job = job - check(job == null || job.data.params.future.isDone) { "Already working on something: $job" } - val future = SettableFuture.create() - 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 - ) : 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) - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt b/app/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt index 8334df78d..918790a45 100644 --- a/app/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt +++ b/app/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt @@ -26,15 +26,16 @@ import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge import org.oxycblt.musikr.Storage import org.oxycblt.musikr.cache.CachedSong import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.cover.CoverParser import org.oxycblt.musikr.fs.query.DeviceFile import org.oxycblt.musikr.metadata.MetadataExtractor import org.oxycblt.musikr.tag.parse.ParsedTags import org.oxycblt.musikr.tag.parse.TagParser +import timber.log.Timber as L interface ExtractStep { fun extract(storage: Storage, nodes: Flow): Flow @@ -42,11 +43,8 @@ interface ExtractStep { class ExtractStepImpl @Inject -constructor( - private val metadataExtractor: MetadataExtractor, - private val tagParser: TagParser, - private val coverParser: CoverParser -) : ExtractStep { +constructor(private val metadataExtractor: MetadataExtractor, private val tagParser: TagParser) : + ExtractStep { override fun extract(storage: Storage, nodes: Flow): Flow { val cacheResults = nodes @@ -63,15 +61,16 @@ constructor( ExtractedMusic.Song(it.file, song.parsedTags, song.cover) } } - val split = uncachedSongs.distribute(8) + val split = uncachedSongs.distribute(16) val extractedSongs = Array(split.hot.size) { i -> split.hot[i] - .map { node -> - val metadata = metadataExtractor.extract(node.file) + .mapNotNull { node -> + val metadata = + metadataExtractor.extract(node.file) ?: return@mapNotNull null + L.d("Extracted tags for ${metadata.id3v2}") val tags = tagParser.parse(node.file, metadata) - val coverData = coverParser.extract(metadata) - val cover = coverData?.let { storage.storedCovers.write(it) } + val cover = metadata.cover?.let { storage.storedCovers.write(it) } ExtractedMusic.Song(node.file, tags, cover) } .flowOn(Dispatchers.IO) diff --git a/app/src/main/java/org/oxycblt/musikr/tag/parse/ExoPlayerTagFields.kt b/app/src/main/java/org/oxycblt/musikr/tag/parse/ExoPlayerTagFields.kt index 3110445cf..76f72fb16 100644 --- a/app/src/main/java/org/oxycblt/musikr/tag/parse/ExoPlayerTagFields.kt +++ b/app/src/main/java/org/oxycblt/musikr/tag/parse/ExoPlayerTagFields.kt @@ -20,37 +20,38 @@ package org.oxycblt.musikr.tag.parse import androidx.core.text.isDigitsOnly import org.oxycblt.auxio.util.nonZeroOrNull +import org.oxycblt.ktaglib.Metadata import org.oxycblt.musikr.tag.Date import org.oxycblt.musikr.tag.util.parseId3v2PositionField -import org.oxycblt.musikr.tag.util.parseVorbisPositionField +import org.oxycblt.musikr.tag.util.parseXiphPositionField // Song -fun ExoPlayerTags.musicBrainzId() = - (vorbis["musicbrainz_releasetrackid"] - ?: vorbis["musicbrainz release track id"] +fun Metadata.musicBrainzId() = + (xiph["musicbrainz_releasetrackid"] + ?: xiph["musicbrainz release track id"] ?: id3v2["TXXX:musicbrainz release track id"] ?: id3v2["TXXX:musicbrainz_releasetrackid"]) ?.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. -fun ExoPlayerTags.track() = - (parseVorbisPositionField( - vorbis["tracknumber"]?.first(), - (vorbis["totaltracks"] ?: vorbis["tracktotal"] ?: vorbis["trackc"])?.first()) +fun Metadata.track() = + (parseXiphPositionField( + xiph["tracknumber"]?.first(), + (xiph["totaltracks"] ?: xiph["tracktotal"] ?: xiph["trackc"])?.first()) ?: id3v2["TRCK"]?.run { first().parseId3v2PositionField() }) // Disc and it's subtitle name. -fun ExoPlayerTags.disc() = - (parseVorbisPositionField( - vorbis["discnumber"]?.first(), - (vorbis["totaldiscs"] ?: vorbis["disctotal"] ?: vorbis["discc"])?.run { first() }) +fun Metadata.disc() = + (parseXiphPositionField( + xiph["discnumber"]?.first(), + (xiph["totaldiscs"] ?: xiph["disctotal"] ?: xiph["discc"])?.run { first() }) ?: 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 // 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: Handle dates that are in "January" because the actual specific release date // isn't known? -fun ExoPlayerTags.date() = - (vorbis["originaldate"]?.run { Date.from(first()) } - ?: vorbis["date"]?.run { Date.from(first()) } - ?: vorbis["year"]?.run { Date.from(first()) } +fun Metadata.date() = + (xiph["originaldate"]?.run { Date.from(first()) } + ?: xiph["date"]?.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: // 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 + // 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!) id3v2["TDOR"]?.run { Date.from(first()) } ?: id3v2["TDRC"]?.run { Date.from(first()) } @@ -82,20 +83,20 @@ fun ExoPlayerTags.date() = ?: parseId3v23Date()) // Album -fun ExoPlayerTags.albumMusicBrainzId() = - (vorbis["musicbrainz_albumid"] - ?: vorbis["musicbrainz album id"] +fun Metadata.albumMusicBrainzId() = + (xiph["musicbrainz_albumid"] + ?: xiph["musicbrainz album id"] ?: id3v2["TXXX:musicbrainz album id"] ?: id3v2["TXXX:musicbrainz_albumid"]) ?.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() = - (vorbis["releasetype"] - ?: vorbis["musicbrainz album type"] +fun Metadata.releaseTypes() = + (xiph["releasetype"] + ?: xiph["musicbrainz album type"] ?: id3v2["TXXX:musicbrainz album type"] ?: id3v2["TXXX:releasetype"] ?: @@ -103,25 +104,25 @@ fun ExoPlayerTags.releaseTypes() = id3v2["GRP1"]) // Artist -fun ExoPlayerTags.artistMusicBrainzIds() = - (vorbis["musicbrainz_artistid"] - ?: vorbis["musicbrainz artist id"] +fun Metadata.artistMusicBrainzIds() = + (xiph["musicbrainz_artistid"] + ?: xiph["musicbrainz artist id"] ?: id3v2["TXXX:musicbrainz artist id"] ?: id3v2["TXXX:musicbrainz_artistid"]) -fun ExoPlayerTags.artistNames() = - (vorbis["artists"] - ?: vorbis["artist"] +fun Metadata.artistNames() = + (xiph["artists"] + ?: xiph["artist"] ?: id3v2["TXXX:artists"] ?: id3v2["TPE1"] ?: id3v2["TXXX:artist"]) -fun ExoPlayerTags.artistSortNames() = - (vorbis["artistssort"] - ?: vorbis["artists_sort"] - ?: vorbis["artists sort"] - ?: vorbis["artistsort"] - ?: vorbis["artist sort"] +fun Metadata.artistSortNames() = + (xiph["artistssort"] + ?: xiph["artists_sort"] + ?: xiph["artists sort"] + ?: xiph["artistsort"] + ?: xiph["artist sort"] ?: id3v2["TXXX:artistssort"] ?: id3v2["TXXX:artists_sort"] ?: id3v2["TXXX:artists sort"] @@ -129,18 +130,18 @@ fun ExoPlayerTags.artistSortNames() = ?: id3v2["artistsort"] ?: id3v2["TXXX:artist sort"]) -fun ExoPlayerTags.albumArtistMusicBrainzIds() = - (vorbis["musicbrainz_albumartistid"] - ?: vorbis["musicbrainz album artist id"] +fun Metadata.albumArtistMusicBrainzIds() = + (xiph["musicbrainz_albumartistid"] + ?: xiph["musicbrainz album artist id"] ?: id3v2["TXXX:musicbrainz album artist id"] ?: id3v2["TXXX:musicbrainz_albumartistid"]) -fun ExoPlayerTags.albumArtistNames() = - (vorbis["albumartists"] - ?: vorbis["album_artists"] - ?: vorbis["album artists"] - ?: vorbis["albumartist"] - ?: vorbis["album artist"] +fun Metadata.albumArtistNames() = + (xiph["albumartists"] + ?: xiph["album_artists"] + ?: xiph["album artists"] + ?: xiph["albumartist"] + ?: xiph["album artist"] ?: id3v2["TXXX:albumartists"] ?: id3v2["TXXX:album_artists"] ?: id3v2["TXXX:album artists"] @@ -148,12 +149,12 @@ fun ExoPlayerTags.albumArtistNames() = ?: id3v2["TXXX:albumartist"] ?: id3v2["TXXX:album artist"]) -fun ExoPlayerTags.albumArtistSortNames() = - (vorbis["albumartistssort"] - ?: vorbis["albumartists_sort"] - ?: vorbis["albumartists sort"] - ?: vorbis["albumartistsort"] - ?: vorbis["album artist sort"] +fun Metadata.albumArtistSortNames() = + (xiph["albumartistssort"] + ?: xiph["albumartists_sort"] + ?: xiph["albumartists sort"] + ?: xiph["albumartistsort"] + ?: xiph["album artist sort"] ?: id3v2["TXXX:albumartistssort"] ?: id3v2["TXXX:albumartists_sort"] ?: id3v2["TXXX:albumartists sort"] @@ -163,12 +164,12 @@ fun ExoPlayerTags.albumArtistSortNames() = ?: id3v2["TXXX:album artist sort"]) // Genre -fun ExoPlayerTags.genreNames() = vorbis["genre"] ?: id3v2["TCON"] +fun Metadata.genreNames() = xiph["genre"] ?: id3v2["TCON"] // Compilation Flag -fun ExoPlayerTags.isCompilation() = - (vorbis["compilation"] - ?: vorbis["itunescompilation"] +fun Metadata.isCompilation() = + (xiph["compilation"] + ?: xiph["itunescompilation"] ?: id3v2["TCMP"] // This is a non-standard itunes extension ?: id3v2["TXXX:compilation"] ?: id3v2["TXXX:itunescompilation"]) @@ -178,17 +179,17 @@ fun ExoPlayerTags.isCompilation() = } // ReplayGain information -fun ExoPlayerTags.replayGainTrackAdjustment() = - (vorbis["r128_track_gain"]?.parseR128Adjustment() - ?: vorbis["replaygain_track_gain"]?.parseReplayGainAdjustment() +fun Metadata.replayGainTrackAdjustment() = + (xiph["r128_track_gain"]?.parseR128Adjustment() + ?: xiph["replaygain_track_gain"]?.parseReplayGainAdjustment() ?: id3v2["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()) -fun ExoPlayerTags.replayGainAlbumAdjustment() = - (vorbis["r128_album_gain"]?.parseR128Adjustment() - ?: vorbis["replaygain_album_gain"]?.parseReplayGainAdjustment() +fun Metadata.replayGainAlbumAdjustment() = + (xiph["r128_album_gain"]?.parseR128Adjustment() + ?: xiph["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 // is present. val year = diff --git a/app/src/main/java/org/oxycblt/musikr/tag/parse/ExoPlayerTags.kt b/app/src/main/java/org/oxycblt/musikr/tag/parse/ExoPlayerTags.kt deleted file mode 100644 index 235d143a1..000000000 --- a/app/src/main/java/org/oxycblt/musikr/tag/parse/ExoPlayerTags.kt +++ /dev/null @@ -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 . - */ - -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>() - /** The ID3v2 text identification frames found in the file. Can have more than one value. */ - val id3v2: Map> - get() = _id3v2 - - private val _vorbis = mutableMapOf>() - /** The vorbis comments found in the file. Can have more than one value. */ - val vorbis: Map> - 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()) -} diff --git a/app/src/main/java/org/oxycblt/musikr/tag/parse/MediaMetadataTagFields.kt b/app/src/main/java/org/oxycblt/musikr/tag/parse/MediaMetadataTagFields.kt deleted file mode 100644 index 7751ffbc7..000000000 --- a/app/src/main/java/org/oxycblt/musikr/tag/parse/MediaMetadataTagFields.kt +++ /dev/null @@ -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 . - */ - -package org.oxycblt.musikr.tag.parse - -import android.media.MediaMetadataRetriever - -fun MediaMetadataRetriever.durationMs() = - extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() diff --git a/app/src/main/java/org/oxycblt/musikr/tag/parse/TagParser.kt b/app/src/main/java/org/oxycblt/musikr/tag/parse/TagParser.kt index bf1071c42..696a5fc4e 100644 --- a/app/src/main/java/org/oxycblt/musikr/tag/parse/TagParser.kt +++ b/app/src/main/java/org/oxycblt/musikr/tag/parse/TagParser.kt @@ -19,48 +19,38 @@ package org.oxycblt.musikr.tag.parse import javax.inject.Inject +import org.oxycblt.ktaglib.Metadata import org.oxycblt.musikr.fs.query.DeviceFile -import org.oxycblt.musikr.metadata.AudioMetadata 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 TagParserImpl @Inject constructor() : TagParser { - override fun parse(file: DeviceFile, metadata: AudioMetadata): 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) + override fun parse(file: DeviceFile, metadata: Metadata): ParsedTags { return ParsedTags( - durationMs = - metadata.mediaMetadataRetriever.durationMs() ?: throw MissingTagError("durationMs"), - replayGainTrackAdjustment = exoPlayerTags.replayGainTrackAdjustment(), - replayGainAlbumAdjustment = exoPlayerTags.replayGainAlbumAdjustment(), - musicBrainzId = exoPlayerTags.musicBrainzId(), - name = exoPlayerTags.name() ?: file.path.name ?: throw MissingTagError("name"), - sortName = exoPlayerTags.sortName(), - track = exoPlayerTags.track(), - disc = exoPlayerTags.disc(), - subtitle = exoPlayerTags.subtitle(), - date = exoPlayerTags.date(), - albumMusicBrainzId = exoPlayerTags.albumMusicBrainzId(), - albumName = exoPlayerTags.albumName(), - albumSortName = exoPlayerTags.albumSortName(), - releaseTypes = exoPlayerTags.releaseTypes() ?: listOf(), - artistMusicBrainzIds = exoPlayerTags.artistMusicBrainzIds() ?: listOf(), - artistNames = exoPlayerTags.artistNames() ?: listOf(), - artistSortNames = exoPlayerTags.artistSortNames() ?: listOf(), - albumArtistMusicBrainzIds = exoPlayerTags.albumArtistMusicBrainzIds() ?: listOf(), - albumArtistNames = exoPlayerTags.albumArtistNames() ?: listOf(), - albumArtistSortNames = exoPlayerTags.albumArtistSortNames() ?: listOf(), - genreNames = exoPlayerTags.genreNames() ?: listOf()) + durationMs = metadata.properties.durationMs, + replayGainTrackAdjustment = metadata.replayGainTrackAdjustment(), + replayGainAlbumAdjustment = metadata.replayGainAlbumAdjustment(), + musicBrainzId = metadata.musicBrainzId(), + name = metadata.name() ?: file.path.name ?: throw MissingTagError("name"), + sortName = metadata.sortName(), + track = metadata.track(), + disc = metadata.disc(), + subtitle = metadata.subtitle(), + date = metadata.date(), + albumMusicBrainzId = metadata.albumMusicBrainzId(), + albumName = metadata.albumName(), + albumSortName = metadata.albumSortName(), + releaseTypes = metadata.releaseTypes() ?: listOf(), + artistMusicBrainzIds = metadata.artistMusicBrainzIds() ?: listOf(), + artistNames = metadata.artistNames() ?: listOf(), + artistSortNames = metadata.artistSortNames() ?: listOf(), + albumArtistMusicBrainzIds = metadata.albumArtistMusicBrainzIds() ?: listOf(), + albumArtistNames = metadata.albumArtistNames() ?: listOf(), + albumArtistSortNames = metadata.albumArtistSortNames() ?: listOf(), + genreNames = metadata.genreNames() ?: listOf()) } } diff --git a/app/src/main/java/org/oxycblt/musikr/tag/util/Vorbis.kt b/app/src/main/java/org/oxycblt/musikr/tag/util/Vorbis.kt index beada6676..7e8378a7a 100644 --- a/app/src/main/java/org/oxycblt/musikr/tag/util/Vorbis.kt +++ b/app/src/main/java/org/oxycblt/musikr/tag/util/Vorbis.kt @@ -47,7 +47,7 @@ fun String.parseId3v2PositionField() = * * @see transformPositionField */ -fun parseVorbisPositionField(pos: String?, total: String?) = +fun parseXiphPositionField(pos: String?, total: String?) = transformPositionField(pos?.toIntOrNull(), total?.toIntOrNull()) /** diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index e1ea02bd1..6bc79e6d3 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -18,7 +18,7 @@ auxio_square_covers auxio_include_dirs auxio_exclude_non_music - auxio_music_locations + auxio_music_locations2 auxio_separators auxio_auto_sort_names diff --git a/app/src/test/java/org/oxycblt/musikr/tag/parse/TextTagsTest.kt b/app/src/test/java/org/oxycblt/musikr/tag/parse/TextTagsTest.kt deleted file mode 100644 index 2f6a75a30..000000000 --- a/app/src/test/java/org/oxycblt/musikr/tag/parse/TextTagsTest.kt +++ /dev/null @@ -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 . - */ - -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())) - } -}