From 069a4c9511f01fb548d9797a33506a08892dc425 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 3 Mar 2023 19:33:44 -0700 Subject: [PATCH] playback: inject extractors Use dependency injection with the custom extractor setup Just looks better this way. --- .../org/oxycblt/auxio/image/ImageModule.kt | 7 +- .../auxio/image/extractor/Components.kt | 30 +- .../{Covers.kt => CoverExtractor.kt} | 90 ++---- .../auxio/music/AudioOnlyExtractors.kt | 47 ---- .../auxio/music/metadata/MetadataModule.kt | 1 + .../auxio/music/metadata/TagExtractor.kt | 259 ++---------------- .../oxycblt/auxio/music/metadata/TagWorker.kt | 257 +++++++++++++++++ .../oxycblt/auxio/playback/PlaybackModule.kt | 38 +++ .../auxio/playback/system/PlaybackService.kt | 6 +- 9 files changed, 360 insertions(+), 375 deletions(-) rename app/src/main/java/org/oxycblt/auxio/image/extractor/{Covers.kt => CoverExtractor.kt} (63%) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/AudioOnlyExtractors.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt index 1520abf1e..8c1e11fb8 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt @@ -27,16 +27,13 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher -import org.oxycblt.auxio.image.extractor.ArtistImageFetcher -import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory -import org.oxycblt.auxio.image.extractor.GenreImageFetcher -import org.oxycblt.auxio.image.extractor.MusicKeyer +import org.oxycblt.auxio.image.extractor.* @Module @InstallIn(SingletonComponent::class) interface ImageModule { @Binds fun settings(imageSettings: ImageSettingsImpl): ImageSettings + @Binds fun coverExtractor(coverExtractor: CoverExtractorImpl): CoverExtractor } @Module diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 38ad9dc59..d1ea6391f 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -31,7 +31,6 @@ import javax.inject.Inject import kotlin.math.min import okio.buffer import okio.source -import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -63,11 +62,11 @@ class MusicKeyer : Keyer { class AlbumCoverFetcher private constructor( private val context: Context, - private val imageSettings: ImageSettings, + private val extractor: CoverExtractor, private val album: Album ) : Fetcher { override suspend fun fetch(): FetchResult? = - Covers.fetch(context, imageSettings, album)?.run { + extractor.extract(album)?.run { SourceResult( source = ImageSource(source().buffer(), context), mimeType = null, @@ -75,17 +74,17 @@ private constructor( } /** A [Fetcher.Factory] implementation that works with [Song]s. */ - class SongFactory @Inject constructor(private val imageSettings: ImageSettings) : + class SongFactory @Inject constructor(private val coverExtractor: CoverExtractor) : Fetcher.Factory { override fun create(data: Song, options: Options, imageLoader: ImageLoader) = - AlbumCoverFetcher(options.context, imageSettings, data.album) + AlbumCoverFetcher(options.context, coverExtractor, data.album) } /** A [Fetcher.Factory] implementation that works with [Album]s. */ - class AlbumFactory @Inject constructor(private val imageSettings: ImageSettings) : + class AlbumFactory @Inject constructor(private val coverExtractor: CoverExtractor) : Fetcher.Factory { override fun create(data: Album, options: Options, imageLoader: ImageLoader) = - AlbumCoverFetcher(options.context, imageSettings, data) + AlbumCoverFetcher(options.context, coverExtractor, data) } } @@ -97,23 +96,22 @@ private constructor( class ArtistImageFetcher private constructor( private val context: Context, - private val imageSettings: ImageSettings, + private val extractor: CoverExtractor, private val size: Size, private val artist: Artist ) : Fetcher { override suspend fun fetch(): FetchResult? { // Pick the "most prominent" albums (i.e albums with the most songs) to show in the image. val albums = Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(artist.albums) - val results = - albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, imageSettings, album) } + val results = albums.mapAtMostNotNull(4) { album -> extractor.extract(album) } return Images.createMosaic(context, results, size) } /** [Fetcher.Factory] implementation. */ - class Factory @Inject constructor(private val imageSettings: ImageSettings) : + class Factory @Inject constructor(private val extractor: CoverExtractor) : Fetcher.Factory { override fun create(data: Artist, options: Options, imageLoader: ImageLoader) = - ArtistImageFetcher(options.context, imageSettings, options.size, data) + ArtistImageFetcher(options.context, extractor, options.size, data) } } @@ -125,20 +123,20 @@ private constructor( class GenreImageFetcher private constructor( private val context: Context, - private val imageSettings: ImageSettings, + private val extractor: CoverExtractor, private val size: Size, private val genre: Genre ) : Fetcher { override suspend fun fetch(): FetchResult? { - val results = genre.albums.mapAtMostNotNull(4) { Covers.fetch(context, imageSettings, it) } + val results = genre.albums.mapAtMostNotNull(4) { album -> extractor.extract(album) } return Images.createMosaic(context, results, size) } /** [Fetcher.Factory] implementation. */ - class Factory @Inject constructor(private val imageSettings: ImageSettings) : + class Factory @Inject constructor(private val extractor: CoverExtractor) : Fetcher.Factory { override fun create(data: Genre, options: Options, imageLoader: ImageLoader) = - GenreImageFetcher(options.context, imageSettings, options.size, data) + GenreImageFetcher(options.context, extractor, options.size, data) } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt similarity index 63% rename from app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index d92924320..b5ef3ecac 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Auxio Project + * Copyright (c) 2023 Auxio Project * * 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 @@ -24,24 +24,20 @@ import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.metadata.flac.PictureFrame import com.google.android.exoplayer2.metadata.id3.ApicFrame -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import com.google.android.exoplayer2.source.MediaSource +import dagger.hilt.android.qualifiers.ApplicationContext import java.io.ByteArrayInputStream import java.io.InputStream +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.AudioOnlyExtractors import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW -/** - * Internal utilities for loading album covers. - * - * @author Alexander Capehart (OxygenCobalt). - */ -object Covers { +interface CoverExtractor { /** * Fetch an album cover, respecting the current cover configuration. * @@ -51,44 +47,35 @@ object Covers { * @return An [InputStream] of image data if the cover loading was successful, null if the cover * loading failed or should not occur. */ - suspend fun fetch(context: Context, imageSettings: ImageSettings, album: Album): InputStream? { - return try { + suspend fun extract(album: Album): InputStream? +} + +class CoverExtractorImpl +@Inject +constructor( + @ApplicationContext private val context: Context, + private val imageSettings: ImageSettings, + private val mediaSourceFactory: MediaSource.Factory +) : CoverExtractor { + + override suspend fun extract(album: Album): InputStream? = + try { when (imageSettings.coverMode) { CoverMode.OFF -> null - CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album) - CoverMode.QUALITY -> fetchQualityCovers(context, album) + CoverMode.MEDIA_STORE -> extractMediaStoreCover(album) + CoverMode.QUALITY -> extractQualityCover(album) } } catch (e: Exception) { logW("Unable to extract album cover due to an error: $e") null } - } - /** - * Load an [Album] cover directly from one of it's Song files. This attempts the following in - * order: - * - [MediaMetadataRetriever], as it has the best support and speed. - * - ExoPlayer's [MetadataRetriever], as some devices (notably Samsung) can have broken - * [MediaMetadataRetriever] implementations. - * - MediaStore, as a last-ditch fallback if the format is really obscure. - * - * @param context [Context] required to load the image. - * @param album [Album] to load the cover from. - * @return An [InputStream] of image data if the cover loading was successful, null otherwise. - */ - private suspend fun fetchQualityCovers(context: Context, album: Album) = - fetchAospMetadataCovers(context, album) - ?: fetchExoplayerCover(context, album) ?: fetchMediaStoreCovers(context, album) + private suspend fun extractQualityCover(album: Album) = + extractAospMetadataCover(album) + ?: extractExoplayerCover(album) ?: extractMediaStoreCover(album) - /** - * Loads an album cover with [MediaMetadataRetriever]. - * - * @param context [Context] required to load the image. - * @param album [Album] to load the cover from. - * @return An [InputStream] of image data if the cover loading was successful, null otherwise. - */ - private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? { - MediaMetadataRetriever().apply { + private fun extractAospMetadataCover(album: Album): InputStream? = + MediaMetadataRetriever().run { // This call is time-consuming but it also doesn't seem to hold up the main thread, // so it's probably fine not to wrap it.rmt setDataSource(context, album.songs[0].uri) @@ -98,20 +85,11 @@ object Covers { // If its null [i.e there is no embedded cover], than just ignore it and move on return embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() } } - } - /** - * Loads an [Album] cover with ExoPlayer's [MetadataRetriever]. - * - * @param context [Context] required to load the image. - * @param album [Album] to load the cover from. - * @return An [InputStream] of image data if the cover loading was successful, null otherwise. - */ - private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? { - val uri = album.songs[0].uri + private suspend fun extractExoplayerCover(album: Album): InputStream? { val future = MetadataRetriever.retrieveMetadata( - DefaultMediaSourceFactory(context, AudioOnlyExtractors), MediaItem.fromUri(uri)) + mediaSourceFactory, MediaItem.fromUri(album.songs[0].uri)) // future.get is a blocking call that makes us spin until the future is done. // This is bad for a co-routine, as it prevents cancellation and by extension @@ -175,18 +153,8 @@ object Covers { return stream } - /** - * Loads an [Album] cover from MediaStore. - * - * @param context [Context] required to load the image. - * @param album [Album] to load the cover from. - * @return An [InputStream] of image data if the cover loading was successful, null otherwise. - */ @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? { + private suspend fun extractMediaStoreCover(album: Album) = // Eliminate any chance that this blocking call might mess up the loading process - return withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(album.coverUri) - } - } + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/AudioOnlyExtractors.kt b/app/src/main/java/org/oxycblt/auxio/music/AudioOnlyExtractors.kt deleted file mode 100644 index 97196dad1..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/AudioOnlyExtractors.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * - * 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 - -import com.google.android.exoplayer2.extractor.ExtractorsFactory -import com.google.android.exoplayer2.extractor.flac.FlacExtractor -import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor -import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor -import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor -import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor -import com.google.android.exoplayer2.extractor.ogg.OggExtractor -import com.google.android.exoplayer2.extractor.ts.AdtsExtractor -import com.google.android.exoplayer2.extractor.wav.WavExtractor - -/** - * A [ExtractorsFactory] that only provides audio containers to save APK space. - * - * @author Alexander Capehart (OxygenCobalt) - */ -object AudioOnlyExtractors : ExtractorsFactory { - override fun createExtractors() = - arrayOf( - FlacExtractor(), - WavExtractor(), - FragmentedMp4Extractor(), - Mp4Extractor(), - OggExtractor(), - MatroskaExtractor(), - // Enable constant bitrate seeking so that certain MP3s/AACs are seekable - AdtsExtractor(AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING), - Mp3Extractor(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING)) -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt index 469d21a7b..ea87a43f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt @@ -26,5 +26,6 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) interface MetadataModule { @Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor + @Binds fun tagWorkerFactory(taskFactory: TagWorkerImpl.Factory): TagWorker.Factory @Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider } 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 index 7859bdccc..f5d96d7aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt @@ -17,20 +17,11 @@ package org.oxycblt.auxio.music.metadata -import android.content.Context -import androidx.core.text.isDigitsOnly -import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield -import org.oxycblt.auxio.music.AudioOnlyExtractors import org.oxycblt.auxio.music.model.RawSong -import org.oxycblt.auxio.music.storage.toAudioUri -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW /** * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the @@ -50,7 +41,7 @@ interface TagExtractor { suspend fun consume(incompleteSongs: Channel, completeSongs: Channel) } -class TagExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) : +class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWorker.Factory) : TagExtractor { override suspend fun consume( incompleteSongs: Channel, @@ -58,22 +49,22 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte ) { // We can parallelize MetadataRetriever Futures to work around it's speed issues, // producing similar throughput's to other kinds of manual metadata extraction. - val taskPool: Array = arrayOfNulls(TASK_CAPACITY) + val tagWorkerPool: Array = arrayOfNulls(TASK_CAPACITY) - for (song in incompleteSongs) { + for (incompleteRawSong in incompleteSongs) { spin@ while (true) { - for (i in taskPool.indices) { - val task = taskPool[i] - if (task != null) { - val finishedRawSong = task.get() - if (finishedRawSong != null) { - completeSongs.send(finishedRawSong) + for (i in tagWorkerPool.indices) { + val worker = tagWorkerPool[i] + if (worker != null) { + val completeRawSong = worker.poll() + if (completeRawSong != null) { + completeSongs.send(completeRawSong) yield() } else { continue } } - taskPool[i] = Task(context, song) + tagWorkerPool[i] = tagWorkerFactory.create(incompleteRawSong) break@spin } } @@ -81,13 +72,13 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte do { var ongoingTasks = false - for (i in taskPool.indices) { - val task = taskPool[i] + for (i in tagWorkerPool.indices) { + val task = tagWorkerPool[i] if (task != null) { - val finishedRawSong = task.get() - if (finishedRawSong != null) { - completeSongs.send(finishedRawSong) - taskPool[i] = null + val completeRawSong = task.poll() + if (completeRawSong != null) { + completeSongs.send(completeRawSong) + tagWorkerPool[i] = null yield() } else { ongoingTasks = true @@ -103,221 +94,3 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte const val TASK_CAPACITY = 8 } } - -/** - * Wraps a [TagExtractor] future and processes it into a [RawSong] when completed. - * - * @param context [Context] required to open the audio file. - * @param rawSong [RawSong] to process. - * @author Alexander Capehart (OxygenCobalt) - */ -private class Task(context: Context, private val rawSong: RawSong) { - // Note that we do not leverage future callbacks. This is because errors in the - // (highly fallible) extraction process will not bubble up to Indexer when a - // listener is used, instead crashing the app entirely. - private val future = - MetadataRetriever.retrieveMetadata( - DefaultMediaSourceFactory(context, AudioOnlyExtractors), - MediaItem.fromUri( - requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())) - - /** - * Try to get a completed song from this [Task], if it has finished processing. - * - * @return A [RawSong] instance if processing has completed, null otherwise. - */ - fun get(): RawSong? { - if (!future.isDone) { - // Not done yet, nothing to do. - return null - } - - val format = - try { - future.get()[0].getFormat(0) - } catch (e: Exception) { - logW("Unable to extract metadata for ${rawSong.name}") - logW(e.stackTraceToString()) - null - } - if (format == null) { - logD("Nothing could be extracted for ${rawSong.name}") - return rawSong - } - - val metadata = format.metadata - if (metadata != null) { - val textTags = TextTags(metadata) - populateWithId3v2(textTags.id3v2) - populateWithVorbis(textTags.vorbis) - } else { - logD("No metadata could be extracted for ${rawSong.name}") - } - - return rawSong - } - - /** - * Complete this instance's [RawSong] with ID3v2 Text Identification Frames. - * - * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more - * values. - */ - private fun populateWithId3v2(textFrames: Map>) { - // Song - textFrames["TXXX:musicbrainz release track id"]?.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 - (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"]?.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"] ?: textFrames["GRP1"]) - ?.let { rawSong.releaseTypes = it } - - // Artist - textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it } - (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } - (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { - rawSong.artistSortNames = it - } - - // Album artist - textFrames["TXXX:musicbrainz album artist id"]?.let { - rawSong.albumArtistMusicBrainzIds = it - } - (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { - rawSong.albumArtistNames = it - } - (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { - rawSong.albumArtistSortNames = it - } - - // Genre - textFrames["TCON"]?.let { rawSong.genreNames = it } - } - - /** - * Parses the ID3v2.3 timestamp specification into a [Date] from the given Text Identification - * Frames. - * - * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more - * values. - * @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a - * hour/minute value from TIME. No second value is included. The latter two fields may not be - * included in they cannot be parsed. Will be null if a year value could not be parsed. - */ - 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) - } - } - - /** - * Complete this instance's [RawSong] with Vorbis comments. - * - * @param comments A mapping between vorbis comment names and one or more vorbis comment values. - */ - private fun populateWithVorbis(comments: Map>) { - // Song - comments["musicbrainz_releasetrackid"]?.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"]?.let { rawSong.albumMusicBrainzId = it.first() } - comments["album"]?.let { rawSong.albumName = it.first() } - comments["albumsort"]?.let { rawSong.albumSortName = it.first() } - comments["releasetype"]?.let { rawSong.releaseTypes = it } - - // Artist - comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } - (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } - (comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it } - - // Album artist - comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } - (comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it } - (comments["albumartists_sort"] ?: comments["albumartistsort"])?.let { - rawSong.albumArtistSortNames = it - } - - // Genre - comments["genre"]?.let { rawSong.genreNames = it } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt new file mode 100644 index 000000000..b1a908f76 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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 com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MetadataRetriever +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.TrackGroupArray +import java.util.concurrent.Future +import javax.inject.Inject +import org.oxycblt.auxio.music.model.RawSong +import org.oxycblt.auxio.music.storage.toAudioUri +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW + +interface TagWorker { + fun poll(): RawSong? + + interface Factory { + fun create(rawSong: RawSong): TagWorker + } +} + +class TagWorkerImpl(private val rawSong: RawSong, private val future: Future) : + TagWorker { + // Note that we do not leverage future callbacks. This is because errors in the + // (highly fallible) extraction process will not bubble up to Indexer when a + // listener is used, instead crashing the app entirely. + + /** + * Try to get a completed song from this [TagWorker], if it has finished processing. + * + * @return A [RawSong] instance if processing has completed, null otherwise. + */ + override fun poll(): RawSong? { + if (!future.isDone) { + // Not done yet, nothing to do. + return null + } + + val format = + try { + future.get()[0].getFormat(0) + } catch (e: Exception) { + logW("Unable to extract metadata for ${rawSong.name}") + logW(e.stackTraceToString()) + null + } + if (format == null) { + logD("Nothing could be extracted for ${rawSong.name}") + return rawSong + } + + val metadata = format.metadata + if (metadata != null) { + val textTags = TextTags(metadata) + populateWithId3v2(textTags.id3v2) + populateWithVorbis(textTags.vorbis) + } else { + logD("No metadata could be extracted for ${rawSong.name}") + } + + return rawSong + } + + /** + * Complete this instance's [RawSong] with ID3v2 Text Identification Frames. + * + * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more + * values. + */ + private fun populateWithId3v2(textFrames: Map>) { + // Song + textFrames["TXXX:musicbrainz release track id"]?.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 + (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"]?.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"] ?: textFrames["GRP1"]) + ?.let { rawSong.releaseTypes = it } + + // Artist + textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it } + (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } + (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { + rawSong.artistSortNames = it + } + + // Album artist + textFrames["TXXX:musicbrainz album artist id"]?.let { + rawSong.albumArtistMusicBrainzIds = it + } + (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { + rawSong.albumArtistNames = it + } + (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { + rawSong.albumArtistSortNames = it + } + + // Genre + textFrames["TCON"]?.let { rawSong.genreNames = it } + } + + /** + * Parses the ID3v2.3 timestamp specification into a [Date] from the given Text Identification + * Frames. + * + * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more + * values. + * @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a + * hour/minute value from TIME. No second value is included. The latter two fields may not be + * included in they cannot be parsed. Will be null if a year value could not be parsed. + */ + 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) + } + } + + /** + * Complete this instance's [RawSong] with Vorbis comments. + * + * @param comments A mapping between vorbis comment names and one or more vorbis comment values. + */ + private fun populateWithVorbis(comments: Map>) { + // Song + comments["musicbrainz_releasetrackid"]?.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"]?.let { rawSong.albumMusicBrainzId = it.first() } + comments["album"]?.let { rawSong.albumName = it.first() } + comments["albumsort"]?.let { rawSong.albumSortName = it.first() } + comments["releasetype"]?.let { rawSong.releaseTypes = it } + + // Artist + comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } + (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } + (comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it } + + // Album artist + comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } + (comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it } + (comments["albumartists_sort"] ?: comments["albumartistsort"])?.let { + rawSong.albumArtistSortNames = it + } + + // Genre + comments["genre"]?.let { rawSong.genreNames = it } + } + + class Factory @Inject constructor(private val mediaSourceFactory: MediaSource.Factory) : + TagWorker.Factory { + override fun create(rawSong: RawSong) = + TagWorkerImpl( + rawSong, + MetadataRetriever.retrieveMetadata( + mediaSourceFactory, + MediaItem.fromUri( + requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" } + .toAudioUri()))) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt index dc05fc8c1..c2f1adf0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt @@ -17,9 +17,23 @@ package org.oxycblt.auxio.playback +import android.content.Context +import com.google.android.exoplayer2.extractor.ExtractorsFactory +import com.google.android.exoplayer2.extractor.flac.FlacExtractor +import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor +import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor +import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor +import com.google.android.exoplayer2.extractor.ogg.OggExtractor +import com.google.android.exoplayer2.extractor.ts.AdtsExtractor +import com.google.android.exoplayer2.extractor.wav.WavExtractor +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import com.google.android.exoplayer2.source.MediaSource import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.oxycblt.auxio.playback.state.PlaybackStateManager @@ -33,3 +47,27 @@ interface PlaybackModule { fun stateManager(playbackManager: PlaybackStateManagerImpl): PlaybackStateManager @Binds fun settings(playbackSettings: PlaybackSettingsImpl): PlaybackSettings } + +@Module +@InstallIn(SingletonComponent::class) +class ExoPlayerModule { + @Provides + fun mediaSourceFactory( + @ApplicationContext context: Context, + extractorsFactory: ExtractorsFactory + ): MediaSource.Factory = DefaultMediaSourceFactory(context, extractorsFactory) + + @Provides + fun extractorsFactory() = ExtractorsFactory { + arrayOf( + FlacExtractor(), + WavExtractor(), + FragmentedMp4Extractor(), + Mp4Extractor(), + OggExtractor(), + MatroskaExtractor(), + // Enable constant bitrate seeking so that certain MP3s/AACs are seekable + AdtsExtractor(AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING), + Mp3Extractor(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 163b35961..8c131de50 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -36,7 +36,7 @@ import com.google.android.exoplayer2.audio.AudioCapabilities import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer import com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer import com.google.android.exoplayer2.mediacodec.MediaCodecSelector -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import com.google.android.exoplayer2.source.MediaSource import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -44,7 +44,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.AudioOnlyExtractors import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song @@ -85,6 +84,7 @@ class PlaybackService : MusicRepository.Listener { // Player components private lateinit var player: ExoPlayer + @Inject lateinit var mediaSourceFactory: MediaSource.Factory @Inject lateinit var replayGainProcessor: ReplayGainAudioProcessor // System backend components @@ -133,7 +133,7 @@ class PlaybackService : player = ExoPlayer.Builder(this, audioRenderer) - .setMediaSourceFactory(DefaultMediaSourceFactory(this, AudioOnlyExtractors)) + .setMediaSourceFactory(mediaSourceFactory) // Enable automatic WakeLock support .setWakeMode(C.WAKE_MODE_LOCAL) .setAudioAttributes(