diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 5db58c9f7..e2cbfd68b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -37,8 +37,7 @@ import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.info.Name -import org.oxycblt.auxio.music.metadata.Separators -import org.oxycblt.auxio.music.metadata.TagExtractor +import org.oxycblt.auxio.music.stack.interpreter.Separators import org.oxycblt.auxio.music.user.MutableUserLibrary import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.util.DEFAULT_TIMEOUT diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 86aa56e5f..121d93419 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -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.useQuery 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.sendWithTimeout import org.oxycblt.auxio.util.unlikelyToBeNull diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index a00450b8f..2c2deb17a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -36,8 +36,8 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.ReleaseType -import org.oxycblt.auxio.music.metadata.Separators -import org.oxycblt.auxio.music.metadata.parseId3GenreNames +import org.oxycblt.auxio.music.stack.interpreter.Separators +import org.oxycblt.auxio.music.stack.extractor.parseId3GenreNames import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment import org.oxycblt.auxio.util.positiveOrNull import org.oxycblt.auxio.util.toUuidOrNull diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 3b8543f74..8ac512f77 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -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.Volume 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.util.unlikelyToBeNull import timber.log.Timber as L diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index 49cbca524..3dfd91c73 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSeparatorsBinding import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.stack.interpreter.Separators import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import timber.log.Timber as L diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt deleted file mode 100644 index 8e3b3066f..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt +++ /dev/null @@ -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 . - */ - -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, completeSongs: Channel) { - 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(8) { null } - - private class MetadataJob( - val rawSong: RawSong, - val mediaItem: MediaItem, - val future: SettableFuture, - 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(), - 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() - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt deleted file mode 100644 index f87433039..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt +++ /dev/null @@ -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 . - */ - -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>) { - // 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>): 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>) { - // 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.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.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.-]") } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/cache/TagDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/cache/TagDatabase.kt index 45fd145a6..92b0c736f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/cache/TagDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/cache/TagDatabase.kt @@ -30,8 +30,8 @@ import androidx.room.TypeConverter import androidx.room.TypeConverters import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.info.Date -import org.oxycblt.auxio.music.metadata.correctWhitespace -import org.oxycblt.auxio.music.metadata.splitEscaped +import org.oxycblt.auxio.music.stack.extractor.correctWhitespace +import org.oxycblt.auxio.music.stack.extractor.splitEscaped @Database(entities = [Tags::class], version = 50, exportSchema = false) abstract class TagDatabase : RoomDatabase() { diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TagInterpreter2.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TagInterpreter2.kt index b0de6aa1f..61481e653 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TagInterpreter2.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TagInterpreter2.kt @@ -24,8 +24,6 @@ import javax.inject.Inject 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.metadata.parseId3v2PositionField -import org.oxycblt.auxio.music.metadata.parseVorbisPositionField import org.oxycblt.auxio.util.nonZeroOrNull /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TagUtil.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt rename to app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TagUtil.kt index 490f72185..51400d3de 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TagUtil.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.stack.extractor import org.oxycblt.auxio.util.positiveOrNull diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TextTags.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TextTags.kt index a1dd821bb..dc28953b8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TextTags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/extractor/TextTags.kt @@ -22,7 +22,6 @@ 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.auxio.music.metadata.correctWhitespace /** * Processing wrapper for [Metadata] that allows organized access to text-based audio tags. diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/interpreter/Separators.kt similarity index 69% rename from app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt rename to app/src/main/java/org/oxycblt/auxio/music/stack/interpreter/Separators.kt index 678e1ef2f..27c542843 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/interpreter/Separators.kt @@ -1,22 +1,9 @@ -/* - * 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 . - */ - -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.stack.interpreter + +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 diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt index 440f044e3..da8b755c3 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.metadata import org.junit.Assert.assertEquals import org.junit.Test +import org.oxycblt.auxio.music.stack.interpreter.Separators class SeparatorsTest { @Test diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt index 7c900d42c..4f6b8b9ca 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt @@ -20,6 +20,11 @@ package org.oxycblt.auxio.music.metadata import org.junit.Assert.assertEquals 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 { @Test