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