diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/Explorer.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/Explorer.kt index 2fa3acd01..3631ea1a0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/Explorer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/Explorer.kt @@ -3,21 +3,25 @@ package org.oxycblt.auxio.music.stack.explore import android.net.Uri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flattenMerge import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.withIndex import org.oxycblt.auxio.music.stack.explore.cache.TagCache -import org.oxycblt.auxio.music.stack.explore.extractor.ExoPlayerTagExtractor -import org.oxycblt.auxio.music.stack.explore.extractor.TagResult +import org.oxycblt.auxio.music.stack.explore.extractor.TagExtractor +import org.oxycblt.auxio.music.stack.explore.cache.CacheResult import org.oxycblt.auxio.music.stack.explore.fs.DeviceFiles import org.oxycblt.auxio.music.stack.explore.playlists.StoredPlaylists import javax.inject.Inject @@ -34,23 +38,35 @@ data class Files( class ExplorerImpl @Inject constructor( private val deviceFiles: DeviceFiles, private val tagCache: TagCache, - private val tagExtractor: ExoPlayerTagExtractor, + private val tagExtractor: TagExtractor, private val storedPlaylists: StoredPlaylists ) : Explorer { + @OptIn(ExperimentalCoroutinesApi::class) override fun explore(uris: List): Files { val deviceFiles = deviceFiles.explore(uris.asFlow()).flowOn(Dispatchers.IO).buffer() val tagRead = tagCache.read(deviceFiles).flowOn(Dispatchers.IO).buffer() - val (cacheFiles, cacheSongs) = tagRead.results() - val tagExtractor = tagExtractor.process(cacheFiles).flowOn(Dispatchers.IO).buffer() - val (_, extractorSongs) = tagExtractor.results() - val writtenExtractorSongs = tagCache.write(extractorSongs).flowOn(Dispatchers.IO).buffer() + val (uncachedDeviceFiles, cachedAudioFiles) = tagRead.results() + val extractedAudioFiles = uncachedDeviceFiles.split(8).map { + tagExtractor.extract(it).flowOn(Dispatchers.IO).buffer() + }.asFlow().flattenMerge() + val writtenAudioFiles = tagCache.write(extractedAudioFiles).flowOn(Dispatchers.IO).buffer() val playlistFiles = storedPlaylists.read() - return Files(merge(cacheSongs, writtenExtractorSongs), playlistFiles) + return Files(merge(cachedAudioFiles, writtenAudioFiles), playlistFiles) } - private fun Flow.results(): Pair, Flow> { - val files = filterIsInstance().map { it.file } - val songs = filterIsInstance().map { it.audioFile } + private fun Flow.results(): Pair, Flow> { + val shared = shareIn(CoroutineScope(Dispatchers.Main), SharingStarted.WhileSubscribed(), replay = 0) + val files = shared.filterIsInstance().map { it.deviceFile } + val songs = shared.filterIsInstance().map { it.audioFile } return files to songs } + + private fun Flow.split(n: Int): Array> { + val indexed = withIndex() + val shared = indexed.shareIn(CoroutineScope(Dispatchers.Main), SharingStarted.WhileSubscribed(), replay = 0) + return Array(n) { + shared.filter { it.index % n == 0 } + .map { it.value } + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/Files.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/Files.kt index c418c4bb6..61db99837 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/Files.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/Files.kt @@ -40,27 +40,27 @@ data class DeviceFile( */ data class AudioFile( val deviceFile: DeviceFile, - var durationMs: Long? = null, - var replayGainTrackAdjustment: Float? = null, - var replayGainAlbumAdjustment: Float? = null, - var musicBrainzId: String? = null, - var name: String? = null, - var sortName: String? = null, - var track: Int? = null, - var disc: Int? = null, - var subtitle: String? = null, - var date: Date? = null, - var albumMusicBrainzId: String? = null, - var albumName: String? = null, - var albumSortName: String? = null, - var releaseTypes: List = listOf(), - var artistMusicBrainzIds: List = listOf(), - var artistNames: List = listOf(), - var artistSortNames: List = listOf(), - var albumArtistMusicBrainzIds: List = listOf(), - var albumArtistNames: List = listOf(), - var albumArtistSortNames: List = listOf(), - var genreNames: List = listOf() + val durationMs: Long, + val replayGainTrackAdjustment: Float? = null, + val replayGainAlbumAdjustment: Float? = null, + val musicBrainzId: String? = null, + val name: String, + val sortName: String? = null, + val track: Int? = null, + val disc: Int? = null, + val subtitle: String? = null, + val date: Date? = null, + val albumMusicBrainzId: String? = null, + val albumName: String? = null, + val albumSortName: String? = null, + val releaseTypes: List = listOf(), + val artistMusicBrainzIds: List = listOf(), + val artistNames: List = listOf(), + val artistSortNames: List = listOf(), + val albumArtistMusicBrainzIds: List = listOf(), + val albumArtistNames: List = listOf(), + val albumArtistSortNames: List = listOf(), + val genreNames: List = listOf() ) data class PlaylistFile( diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/cache/TagCache.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/cache/TagCache.kt index 56967456a..3664b4d98 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/cache/TagCache.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/cache/TagCache.kt @@ -23,28 +23,29 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import org.oxycblt.auxio.music.stack.explore.AudioFile -import org.oxycblt.auxio.music.stack.explore.extractor.TagResult import org.oxycblt.auxio.music.stack.explore.DeviceFile +sealed interface CacheResult { + data class Hit(val audioFile: AudioFile) : CacheResult + data class Miss(val deviceFile: DeviceFile) : CacheResult +} interface TagCache { - fun read(files: Flow): Flow + fun read(files: Flow): Flow fun write(rawSongs: Flow): Flow } class TagCacheImpl @Inject constructor(private val tagDao: TagDao) : TagCache { override fun read(files: Flow) = - files.transform { file -> + files.transform { file -> val tags = tagDao.selectTags(file.uri.toString(), file.lastModified) if (tags != null) { - val audioFile = AudioFile(deviceFile = file) - tags.copyToRaw(audioFile) - TagResult.Hit(audioFile) + CacheResult.Hit(tags.toAudioFile(file)) } else { - TagResult.Miss(file) + CacheResult.Miss(file) } } override fun write(rawSongs: Flow) = - rawSongs.onEach { rawSong -> tagDao.updateTags(Tags.fromRaw(rawSong)) } + rawSongs.onEach { file -> tagDao.updateTags(Tags.fromAudioFile(file)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/cache/TagDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/cache/TagDatabase.kt index 84ab3c0a4..3ce0e3181 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/cache/TagDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/cache/TagDatabase.kt @@ -30,6 +30,7 @@ import androidx.room.TypeConverter import androidx.room.TypeConverters import org.oxycblt.auxio.music.stack.explore.AudioFile import org.oxycblt.auxio.music.info.Date +import org.oxycblt.auxio.music.stack.explore.DeviceFile import org.oxycblt.auxio.music.stack.explore.extractor.correctWhitespace import org.oxycblt.auxio.music.stack.explore.extractor.splitEscaped @@ -59,76 +60,70 @@ data class Tags( /** @see AudioFile */ val durationMs: Long, /** @see AudioFile.replayGainTrackAdjustment */ - val replayGainTrackAdjustment: Float? = null, + val replayGainTrackAdjustment: Float?, /** @see AudioFile.replayGainAlbumAdjustment */ - val replayGainAlbumAdjustment: Float? = null, + val replayGainAlbumAdjustment: Float?, /** @see AudioFile.musicBrainzId */ - var musicBrainzId: String? = null, + val musicBrainzId: String?, /** @see AudioFile.name */ - var name: String, + val name: String, /** @see AudioFile.sortName */ - var sortName: String? = null, + val sortName: String?, /** @see AudioFile.track */ - var track: Int? = null, + val track: Int?, /** @see AudioFile.name */ - var disc: Int? = null, + val disc: Int?, /** @See AudioFile.subtitle */ - var subtitle: String? = null, + val subtitle: String?, /** @see AudioFile.date */ - var date: Date? = null, + val date: Date?, /** @see AudioFile.albumMusicBrainzId */ - var albumMusicBrainzId: String? = null, + val albumMusicBrainzId: String?, /** @see AudioFile.albumName */ - var albumName: String, + val albumName: String?, /** @see AudioFile.albumSortName */ - var albumSortName: String? = null, + val albumSortName: String?, /** @see AudioFile.releaseTypes */ - var releaseTypes: List = listOf(), + val releaseTypes: List = listOf(), /** @see AudioFile.artistMusicBrainzIds */ - var artistMusicBrainzIds: List = listOf(), + val artistMusicBrainzIds: List = listOf(), /** @see AudioFile.artistNames */ - var artistNames: List = listOf(), + val artistNames: List = listOf(), /** @see AudioFile.artistSortNames */ - var artistSortNames: List = listOf(), + val artistSortNames: List = listOf(), /** @see AudioFile.albumArtistMusicBrainzIds */ - var albumArtistMusicBrainzIds: List = listOf(), + val albumArtistMusicBrainzIds: List = listOf(), /** @see AudioFile.albumArtistNames */ - var albumArtistNames: List = listOf(), + val albumArtistNames: List = listOf(), /** @see AudioFile.albumArtistSortNames */ - var albumArtistSortNames: List = listOf(), + val albumArtistSortNames: List = listOf(), /** @see AudioFile.genreNames */ - var genreNames: List = listOf() + val genreNames: List = listOf() ) { - fun copyToRaw(audioFile: AudioFile) { - audioFile.musicBrainzId = musicBrainzId - audioFile.name = name - audioFile.sortName = sortName - - audioFile.durationMs = durationMs - - audioFile.replayGainTrackAdjustment = replayGainTrackAdjustment - audioFile.replayGainAlbumAdjustment = replayGainAlbumAdjustment - - audioFile.track = track - audioFile.disc = disc - audioFile.subtitle = subtitle - audioFile.date = date - - audioFile.albumMusicBrainzId = albumMusicBrainzId - audioFile.albumName = albumName - audioFile.albumSortName = albumSortName - audioFile.releaseTypes = releaseTypes - - audioFile.artistMusicBrainzIds = artistMusicBrainzIds - audioFile.artistNames = artistNames - audioFile.artistSortNames = artistSortNames - - audioFile.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds - audioFile.albumArtistNames = albumArtistNames - audioFile.albumArtistSortNames = albumArtistSortNames - - audioFile.genreNames = genreNames - } + fun toAudioFile(deviceFile: DeviceFile) = + AudioFile( + deviceFile = deviceFile, + musicBrainzId = musicBrainzId, + name = name, + sortName = sortName, + durationMs = durationMs, + replayGainTrackAdjustment = replayGainTrackAdjustment, + replayGainAlbumAdjustment = replayGainAlbumAdjustment, + track = track, + disc = disc, + subtitle = subtitle, + date = date, + albumMusicBrainzId = albumMusicBrainzId, + albumName = albumName, + albumSortName = albumSortName, + releaseTypes = releaseTypes, + artistMusicBrainzIds = artistMusicBrainzIds, + artistNames = artistNames, + artistSortNames = artistSortNames, + albumArtistMusicBrainzIds = albumArtistMusicBrainzIds, + albumArtistNames = albumArtistNames, + albumArtistSortNames = albumArtistSortNames, + genreNames = genreNames) object Converters { @TypeConverter @@ -144,14 +139,14 @@ data class Tags( } companion object { - fun fromRaw(audioFile: AudioFile) = + fun fromAudioFile(audioFile: AudioFile) = Tags( uri = audioFile.deviceFile.uri.toString(), dateModified = audioFile.deviceFile.lastModified, musicBrainzId = audioFile.musicBrainzId, - name = requireNotNull(audioFile.name) { "Invalid raw: No name" }, + name = audioFile.name, sortName = audioFile.sortName, - durationMs = requireNotNull(audioFile.durationMs) { "Invalid raw: No duration" }, + durationMs = audioFile.durationMs, replayGainTrackAdjustment = audioFile.replayGainTrackAdjustment, replayGainAlbumAdjustment = audioFile.replayGainAlbumAdjustment, track = audioFile.track, @@ -159,7 +154,7 @@ data class Tags( subtitle = audioFile.subtitle, date = audioFile.date, albumMusicBrainzId = audioFile.albumMusicBrainzId, - albumName = requireNotNull(audioFile.albumName) { "Invalid raw: No album name" }, + albumName = audioFile.albumName, albumSortName = audioFile.albumSortName, releaseTypes = audioFile.releaseTypes, artistMusicBrainzIds = audioFile.artistMusicBrainzIds, diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/ExoPlayerTagExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/ExoPlayerTagExtractor.kt deleted file mode 100644 index 79e8ffbf1..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/ExoPlayerTagExtractor.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * ExoPlayerTagExtractor.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.stack.explore.extractor - -import android.os.HandlerThread -import androidx.media3.common.MediaItem -import androidx.media3.exoplayer.MetadataRetriever -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.source.TrackGroupArray -import java.util.concurrent.Future -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.flow -import org.oxycblt.auxio.music.stack.explore.AudioFile -import org.oxycblt.auxio.music.stack.explore.DeviceFile -import timber.log.Timber as L - -interface TagResult { - class Hit(val audioFile: AudioFile) : TagResult - - class Miss(val file: DeviceFile) : TagResult -} - -interface ExoPlayerTagExtractor { - fun process(deviceFiles: Flow): Flow -} - -class ExoPlayerTagExtractorImpl -@Inject -constructor( - private val mediaSourceFactory: MediaSource.Factory, - private val tagInterpreter2: TagInterpreter, -) : ExoPlayerTagExtractor { - override fun process(deviceFiles: Flow) = flow { - val threadPool = ThreadPool(8, Handler(this)) - deviceFiles.collect { file -> threadPool.enqueue(file) } - threadPool.empty() - } - - private inner class Handler(private val collector: FlowCollector) : - ThreadPool.Handler { - override suspend fun produce(thread: HandlerThread, input: DeviceFile) = - MetadataRetriever.retrieveMetadata( - mediaSourceFactory, MediaItem.fromUri(input.uri), thread) - - override suspend fun consume(input: DeviceFile, output: TrackGroupArray) { - if (output.isEmpty) { - noMetadata(input) - return - } - val track = output.get(0) - if (track.length == 0) { - noMetadata(input) - return - } - val metadata = track.getFormat(0).metadata - if (metadata == null) { - noMetadata(input) - return - } - val textTags = TextTags(metadata) - val audioFile = AudioFile(deviceFile = input) - tagInterpreter2.interpretOn(textTags, audioFile) - collector.emit(TagResult.Hit(audioFile)) - } - - private suspend fun noMetadata(input: DeviceFile) { - L.e("No metadata found for $input") - collector.emit(TagResult.Miss(input)) - } - } -} - -private class ThreadPool(size: Int, private val handler: Handler) { - private val slots = - Array>(size) { - Slot(thread = HandlerThread("Auxio:ThreadPool:$it"), task = null) - } - - suspend fun enqueue(input: I) { - spin@ while (true) { - for (slot in slots) { - val task = slot.task - if (task == null || task.future.isDone) { - task?.complete() - slot.task = Task(input, handler.produce(slot.thread, input)) - break@spin - } - } - } - } - - suspend fun empty() { - spin@ while (true) { - val slot = slots.firstOrNull { it.task != null } - if (slot == null) { - break@spin - } - val task = slot.task - if (task != null && task.future.isDone) { - task.complete() - slot.task = null - } - } - } - - private suspend fun Task.complete() { - try { - // In-practice this should never block, as all clients - // check if the future is done before calling this function. - // If you don't maintain that invariant, this will explode. - @Suppress("BlockingMethodInNonBlockingContext") handler.consume(input, future.get()) - } catch (e: Exception) { - L.e("Failed to complete task for $input, ${e.stackTraceToString()}") - } - } - - private data class Slot(val thread: HandlerThread, var task: Task?) - - private data class Task(val input: I, val future: Future) - - interface Handler { - suspend fun produce(thread: HandlerThread, input: I): Future - - suspend fun consume(input: I, output: O) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagExtractor.kt new file mode 100644 index 000000000..e2da48e2f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagExtractor.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 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.stack.explore.extractor + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.os.HandlerThread +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.MetadataRetriever +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.TrackGroupArray +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.guava.asDeferred +import javax.inject.Inject +import org.oxycblt.auxio.music.stack.explore.AudioFile +import org.oxycblt.auxio.music.stack.explore.DeviceFile + + +interface TagExtractor { + fun extract( + deviceFiles: Flow + ): Flow +} + +class TagExtractorImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val mediaSourceFactory: MediaSource.Factory, +) : TagExtractor { + override fun extract( + deviceFiles: Flow + ) = flow { + val thread = HandlerThread("TagExtractor:${hashCode()}") + deviceFiles.collect { deviceFile -> + val exoPlayerMetadataFuture = + MetadataRetriever.retrieveMetadata( + mediaSourceFactory, MediaItem.fromUri(deviceFile.uri), thread + ) + val mediaMetadataRetriever = MediaMetadataRetriever() + mediaMetadataRetriever.setDataSource(context, deviceFile.uri) + val exoPlayerMetadata = exoPlayerMetadataFuture.asDeferred().await() + val result = extractTags(deviceFile, exoPlayerMetadata, mediaMetadataRetriever) + mediaMetadataRetriever.close() + emit(result) + } + } + + private fun extractTags( + input: DeviceFile, + output: TrackGroupArray, + retriever: MediaMetadataRetriever + ): AudioFile { + if (output.isEmpty) return defaultAudioFile(input, retriever) + val track = output.get(0) + if (track.length == 0) return defaultAudioFile(input, retriever) + val format = track.getFormat(0) + val metadata = format.metadata ?: return defaultAudioFile(input, retriever) + val textTags = TextTags(metadata) + return AudioFile( + deviceFile = input, + durationMs = need(retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_DURATION + )?.toLong(), "duration"), + replayGainTrackAdjustment = textTags.replayGainTrackAdjustment(), + replayGainAlbumAdjustment = textTags.replayGainAlbumAdjustment(), + musicBrainzId = textTags.musicBrainzId(), + name = need(textTags.name() ?: input.path.name, "name"), + sortName = textTags.sortName(), + track = textTags.track(), + disc = textTags.disc(), + subtitle = textTags.subtitle(), + date = textTags.date(), + albumMusicBrainzId = textTags.albumMusicBrainzId(), + albumName = textTags.albumName(), + albumSortName = textTags.albumSortName(), + releaseTypes = textTags.releaseTypes() ?: listOf(), + artistMusicBrainzIds = textTags.artistMusicBrainzIds() ?: listOf(), + artistNames = textTags.artistNames() ?: listOf(), + artistSortNames = textTags.artistSortNames() ?: listOf(), + albumArtistMusicBrainzIds = textTags.albumArtistMusicBrainzIds() ?: listOf(), + albumArtistNames = textTags.albumArtistNames() ?: listOf(), + albumArtistSortNames = textTags.albumArtistSortNames() ?: listOf(), + genreNames = textTags.genreNames() ?: listOf() + ) + } + + private fun defaultAudioFile(deviceFile: DeviceFile, metadataRetriever: MediaMetadataRetriever) = + AudioFile( + deviceFile, + name = need(deviceFile.path.name, "name"), + durationMs = need(metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong(), "duration"), + ) + + private fun need(a: T, called: String) = + requireNotNull(a) { "Invalid tag, missing $called" } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagFields.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagFields.kt new file mode 100644 index 000000000..580225019 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagFields.kt @@ -0,0 +1,210 @@ +package org.oxycblt.auxio.music.stack.explore.extractor + +import androidx.core.text.isDigitsOnly +import org.oxycblt.auxio.music.info.Date +import org.oxycblt.auxio.util.nonZeroOrNull + +// Song +fun TextTags.musicBrainzId() = + (vorbis["musicbrainz_releasetrackid"] ?: vorbis["musicbrainz release track id"] + ?: id3v2["TXXX:musicbrainz release track id"] + ?: id3v2["TXXX:musicbrainz_releasetrackid"])?.first() + +fun TextTags.name() = (vorbis["title"] ?: id3v2["TIT2"])?.first() +fun TextTags.sortName() = (vorbis["titlesort"] ?: id3v2["TSOT"])?.first() + +// Track. +fun TextTags.track() = (parseVorbisPositionField( + vorbis["tracknumber"]?.first(), + (vorbis["totaltracks"] ?: vorbis["tracktotal"] ?: vorbis["trackc"])?.first() +) + ?: id3v2["TRCK"]?.run { first().parseId3v2PositionField() }) + +// Disc and it's subtitle name. +fun TextTags.disc() = (parseVorbisPositionField( + vorbis["discnumber"]?.first(), + (vorbis["totaldiscs"] ?: vorbis["disctotal"] ?: vorbis["discc"])?.run { first() }) + ?: id3v2["TPOS"]?.run { first().parseId3v2PositionField() }) + +fun TextTags.subtitle() = + (vorbis["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 +// 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? +fun TextTags.date() = (vorbis["originaldate"]?.run { Date.from(first()) } + ?: vorbis["date"]?.run { Date.from(first()) } + ?: vorbis["year"]?.run { Date.from(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!) + id3v2["TDOR"]?.run { Date.from(first()) } + ?: id3v2["TDRC"]?.run { Date.from(first()) } + ?: id3v2["TDRL"]?.run { Date.from(first()) } + ?: parseId3v23Date()) + +// Album +fun TextTags.albumMusicBrainzId() = + (vorbis["musicbrainz_albumid"] ?: vorbis["musicbrainz album id"] + ?: id3v2["TXXX:musicbrainz album id"] ?: id3v2["TXXX:musicbrainz_albumid"])?.first() + +fun TextTags.albumName() = (vorbis["album"] ?: id3v2["TALB"])?.first() +fun TextTags.albumSortName() = (vorbis["albumsort"] ?: id3v2["TSOA"])?.first() +fun TextTags.releaseTypes() = ( + vorbis["releasetype"] ?: vorbis["musicbrainz album type"] + ?: id3v2["TXXX:musicbrainz album type"] + ?: id3v2["TXXX:releasetype"] + ?: + // This is a non-standard iTunes extension + id3v2["GRP1"] + ) + +// Artist +fun TextTags.artistMusicBrainzIds() = + (vorbis["musicbrainz_artistid"] ?: vorbis["musicbrainz artist id"] + ?: id3v2["TXXX:musicbrainz artist id"] ?: id3v2["TXXX:musicbrainz_artistid"]) + +fun TextTags.artistNames() = (vorbis["artists"] ?: vorbis["artist"] ?: id3v2["TXXX:artists"] +?: id3v2["TPE1"] ?: id3v2["TXXX:artist"]) + +fun TextTags.artistSortNames() = (vorbis["artistssort"] + ?: vorbis["artists_sort"] + ?: vorbis["artists sort"] + ?: vorbis["artistsort"] + ?: vorbis["artist sort"] ?: id3v2["TXXX:artistssort"] + ?: id3v2["TXXX:artists_sort"] + ?: id3v2["TXXX:artists sort"] + ?: id3v2["TSOP"] + ?: id3v2["artistsort"] + ?: id3v2["TXXX:artist sort"] + ) + +fun TextTags.albumArtistMusicBrainzIds() = ( + vorbis["musicbrainz_albumartistid"] ?: vorbis["musicbrainz album artist id"] + ?: id3v2["TXXX:musicbrainz album artist id"] + ?: id3v2["TXXX:musicbrainz_albumartistid"] + ) + +fun TextTags.albumArtistNames() = ( + vorbis["albumartists"] + ?: vorbis["album_artists"] + ?: vorbis["album artists"] + ?: vorbis["albumartist"] + ?: vorbis["album artist"] + ?: id3v2["TXXX:albumartists"] + ?: id3v2["TXXX:album_artists"] + ?: id3v2["TXXX:album artists"] + ?: id3v2["TPE2"] + ?: id3v2["TXXX:albumartist"] + ?: id3v2["TXXX:album artist"] + ) + +fun TextTags.albumArtistSortNames() = (vorbis["albumartistssort"] + ?: vorbis["albumartists_sort"] + ?: vorbis["albumartists sort"] + ?: vorbis["albumartistsort"] + ?: vorbis["album artist sort"] ?: id3v2["TXXX:albumartistssort"] + ?: id3v2["TXXX:albumartists_sort"] + ?: id3v2["TXXX:albumartists sort"] + ?: id3v2["TXXX:albumartistsort"] + // This is a non-standard iTunes extension + ?: id3v2["TSO2"] + ?: id3v2["TXXX:album artist sort"] + ) + +// Genre +fun TextTags.genreNames() = vorbis["genre"] ?: id3v2["TCON"] + +// Compilation Flag +fun TextTags.isCompilation() = (vorbis["compilation"] ?: vorbis["itunescompilation"] +?: id3v2["TCMP"] // This is a non-standard itunes extension +?: id3v2["TXXX:compilation"] ?: id3v2["TXXX:itunescompilation"] + ) + ?.let { + // Ignore invalid instances of this tag + it == listOf("1") + } + +// ReplayGain information +fun TextTags.replayGainTrackAdjustment() = (vorbis["r128_track_gain"]?.parseR128Adjustment() + ?: vorbis["replaygain_track_gain"]?.parseReplayGainAdjustment() + ?: id3v2["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()) + +fun TextTags.replayGainAlbumAdjustment() = (vorbis["r128_album_gain"]?.parseR128Adjustment() + ?: vorbis["replaygain_album_gain"]?.parseReplayGainAdjustment() + ?: id3v2["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()) + +private fun TextTags.parseId3v23Date(): Date? { + // Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY + // is present. + val year = + id3v2["TORY"]?.run { first().toIntOrNull() } + ?: id3v2["TYER"]?.run { first().toIntOrNull() } + ?: return null + + val tdat = id3v2["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 = id3v2["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 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() + + +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.-]") } \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagInterpreter.kt b/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagInterpreter.kt deleted file mode 100644 index 8a57a9ee6..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/stack/explore/extractor/TagInterpreter.kt +++ /dev/null @@ -1,317 +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.stack.explore.extractor - -import androidx.core.text.isDigitsOnly -import androidx.media3.exoplayer.MetadataRetriever -import javax.inject.Inject -import org.oxycblt.auxio.music.stack.explore.AudioFile -import org.oxycblt.auxio.music.info.Date -import org.oxycblt.auxio.util.nonZeroOrNull - -/** - * An processing abstraction over the [MetadataRetriever] and [TextTags] workflow that operates on - * [AudioFile] instances. - * - * @author Alexander Capehart (OxygenCobalt) - */ -interface TagInterpreter { - /** - * Poll to see if this worker is done processing. - * - * @return A completed [AudioFile] if done, null otherwise. - */ - fun interpretOn(textTags: TextTags, audioFile: AudioFile) -} - -class TagInterpreterImpl @Inject constructor() : TagInterpreter { - override fun interpretOn(textTags: TextTags, audioFile: AudioFile) { - populateWithId3v2(audioFile, textTags.id3v2) - populateWithVorbis(audioFile, textTags.vorbis) - } - - private fun populateWithId3v2(audioFile: AudioFile, textFrames: Map>) { - // Song - (textFrames["TXXX:musicbrainz release track id"] - ?: textFrames["TXXX:musicbrainz_releasetrackid"]) - ?.let { audioFile.musicBrainzId = it.first() } - textFrames["TIT2"]?.let { audioFile.name = it.first() } - textFrames["TSOT"]?.let { audioFile.sortName = it.first() } - - // Track. - textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { audioFile.track = it } - - // Disc and it's subtitle name. - textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { audioFile.disc = it } - textFrames["TSST"]?.let { audioFile.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 { audioFile.date = it } - - // Album - (textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let { - audioFile.albumMusicBrainzId = it.first() - } - textFrames["TALB"]?.let { audioFile.albumName = it.first() } - textFrames["TSOA"]?.let { audioFile.albumSortName = it.first() } - (textFrames["TXXX:musicbrainz album type"] - ?: textFrames["TXXX:releasetype"] - ?: - // This is a non-standard iTunes extension - textFrames["GRP1"]) - ?.let { audioFile.releaseTypes = it } - - // Artist - (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let { - audioFile.artistMusicBrainzIds = it - } - (textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let { - audioFile.artistNames = it - } - (textFrames["TXXX:artistssort"] - ?: textFrames["TXXX:artists_sort"] - ?: textFrames["TXXX:artists sort"] - ?: textFrames["TSOP"] - ?: textFrames["artistsort"] - ?: textFrames["TXXX:artist sort"]) - ?.let { audioFile.artistSortNames = it } - - // Album artist - (textFrames["TXXX:musicbrainz album artist id"] - ?: textFrames["TXXX:musicbrainz_albumartistid"]) - ?.let { audioFile.albumArtistMusicBrainzIds = it } - (textFrames["TXXX:albumartists"] - ?: textFrames["TXXX:album_artists"] - ?: textFrames["TXXX:album artists"] - ?: textFrames["TPE2"] - ?: textFrames["TXXX:albumartist"] - ?: textFrames["TXXX:album artist"]) - ?.let { audioFile.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 { audioFile.albumArtistSortNames = it } - - // Genre - textFrames["TCON"]?.let { audioFile.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" - audioFile.albumArtistNames = - audioFile.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } - audioFile.releaseTypes = audioFile.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } - } - - // ReplayGain information - textFrames["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()?.let { - audioFile.replayGainTrackAdjustment = it - } - textFrames["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()?.let { - audioFile.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(audioFile: AudioFile, comments: Map>) { - // Song - (comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let { - audioFile.musicBrainzId = it.first() - } - comments["title"]?.let { audioFile.name = it.first() } - comments["titlesort"]?.let { audioFile.sortName = it.first() } - - // Track. - parseVorbisPositionField( - comments["tracknumber"]?.first(), - (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()) - ?.let { audioFile.track = it } - - // Disc and it's subtitle name. - parseVorbisPositionField( - comments["discnumber"]?.first(), - (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()) - ?.let { audioFile.disc = it } - comments["discsubtitle"]?.let { audioFile.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 { audioFile.date = it } - - // Album - (comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let { - audioFile.albumMusicBrainzId = it.first() - } - comments["album"]?.let { audioFile.albumName = it.first() } - comments["albumsort"]?.let { audioFile.albumSortName = it.first() } - (comments["releasetype"] ?: comments["musicbrainz album type"])?.let { - audioFile.releaseTypes = it - } - - // Artist - (comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let { - audioFile.artistMusicBrainzIds = it - } - (comments["artists"] ?: comments["artist"])?.let { audioFile.artistNames = it } - (comments["artistssort"] - ?: comments["artists_sort"] - ?: comments["artists sort"] - ?: comments["artistsort"] - ?: comments["artist sort"]) - ?.let { audioFile.artistSortNames = it } - - // Album artist - (comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let { - audioFile.albumArtistMusicBrainzIds = it - } - (comments["albumartists"] - ?: comments["album_artists"] - ?: comments["album artists"] - ?: comments["albumartist"] - ?: comments["album artist"]) - ?.let { audioFile.albumArtistNames = it } - (comments["albumartistssort"] - ?: comments["albumartists_sort"] - ?: comments["albumartists sort"] - ?: comments["albumartistsort"] - ?: comments["album artist sort"]) - ?.let { audioFile.albumArtistSortNames = it } - - // Genre - comments["genre"]?.let { audioFile.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" - audioFile.albumArtistNames = - audioFile.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } - audioFile.releaseTypes = audioFile.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 { audioFile.replayGainTrackAdjustment = it } - (comments["r128_album_gain"]?.parseR128Adjustment() - ?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment()) - ?.let { audioFile.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.-]") } - } -}