playback: inject extractors

Use dependency injection with the custom extractor setup

Just looks better this way.
This commit is contained in:
Alexander Capehart 2023-03-03 19:33:44 -07:00
parent e4aa409cbc
commit 069a4c9511
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 360 additions and 375 deletions

View file

@ -27,16 +27,13 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher import org.oxycblt.auxio.image.extractor.*
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
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface ImageModule { interface ImageModule {
@Binds fun settings(imageSettings: ImageSettingsImpl): ImageSettings @Binds fun settings(imageSettings: ImageSettingsImpl): ImageSettings
@Binds fun coverExtractor(coverExtractor: CoverExtractorImpl): CoverExtractor
} }
@Module @Module

View file

@ -31,7 +31,6 @@ import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -63,11 +62,11 @@ class MusicKeyer : Keyer<Music> {
class AlbumCoverFetcher class AlbumCoverFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val imageSettings: ImageSettings, private val extractor: CoverExtractor,
private val album: Album private val album: Album
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? = override suspend fun fetch(): FetchResult? =
Covers.fetch(context, imageSettings, album)?.run { extractor.extract(album)?.run {
SourceResult( SourceResult(
source = ImageSource(source().buffer(), context), source = ImageSource(source().buffer(), context),
mimeType = null, mimeType = null,
@ -75,17 +74,17 @@ private constructor(
} }
/** A [Fetcher.Factory] implementation that works with [Song]s. */ /** 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<Song> { Fetcher.Factory<Song> {
override fun create(data: Song, options: Options, imageLoader: ImageLoader) = 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. */ /** 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<Album> { Fetcher.Factory<Album> {
override fun create(data: Album, options: Options, imageLoader: ImageLoader) = 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 class ArtistImageFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val imageSettings: ImageSettings, private val extractor: CoverExtractor,
private val size: Size, private val size: Size,
private val artist: Artist private val artist: Artist
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
// Pick the "most prominent" albums (i.e albums with the most songs) to show in the image. // 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 albums = Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(artist.albums)
val results = val results = albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, imageSettings, album) }
return Images.createMosaic(context, results, size) return Images.createMosaic(context, results, size)
} }
/** [Fetcher.Factory] implementation. */ /** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val imageSettings: ImageSettings) : class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Artist> { Fetcher.Factory<Artist> {
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) = 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 class GenreImageFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val imageSettings: ImageSettings, private val extractor: CoverExtractor,
private val size: Size, private val size: Size,
private val genre: Genre private val genre: Genre
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { 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) return Images.createMosaic(context, results, size)
} }
/** [Fetcher.Factory] implementation. */ /** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val imageSettings: ImageSettings) : class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Genre> { Fetcher.Factory<Genre> {
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) = override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
GenreImageFetcher(options.context, imageSettings, options.size, data) GenreImageFetcher(options.context, extractor, options.size, data)
} }
} }

View file

@ -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 * 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 * 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.MetadataRetriever
import com.google.android.exoplayer2.metadata.flac.PictureFrame import com.google.android.exoplayer2.metadata.flac.PictureFrame
import com.google.android.exoplayer2.metadata.id3.ApicFrame 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.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.AudioOnlyExtractors
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
/** interface CoverExtractor {
* Internal utilities for loading album covers.
*
* @author Alexander Capehart (OxygenCobalt).
*/
object Covers {
/** /**
* Fetch an album cover, respecting the current cover configuration. * 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 * @return An [InputStream] of image data if the cover loading was successful, null if the cover
* loading failed or should not occur. * loading failed or should not occur.
*/ */
suspend fun fetch(context: Context, imageSettings: ImageSettings, album: Album): InputStream? { suspend fun extract(album: Album): InputStream?
return try { }
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) { when (imageSettings.coverMode) {
CoverMode.OFF -> null CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album) CoverMode.MEDIA_STORE -> extractMediaStoreCover(album)
CoverMode.QUALITY -> fetchQualityCovers(context, album) CoverMode.QUALITY -> extractQualityCover(album)
} }
} catch (e: Exception) { } catch (e: Exception) {
logW("Unable to extract album cover due to an error: $e") logW("Unable to extract album cover due to an error: $e")
null null
} }
}
/** private suspend fun extractQualityCover(album: Album) =
* Load an [Album] cover directly from one of it's Song files. This attempts the following in extractAospMetadataCover(album)
* order: ?: extractExoplayerCover(album) ?: extractMediaStoreCover(album)
* - [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 fun extractAospMetadataCover(album: Album): InputStream? =
* Loads an album cover with [MediaMetadataRetriever]. MediaMetadataRetriever().run {
*
* @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 {
// This call is time-consuming but it also doesn't seem to hold up the main thread, // 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 // so it's probably fine not to wrap it.rmt
setDataSource(context, album.songs[0].uri) 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 // If its null [i.e there is no embedded cover], than just ignore it and move on
return embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() } return embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
} }
}
/** private suspend fun extractExoplayerCover(album: Album): InputStream? {
* 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
val future = val future =
MetadataRetriever.retrieveMetadata( 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. // 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 // This is bad for a co-routine, as it prevents cancellation and by extension
@ -175,18 +153,8 @@ object Covers {
return stream 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") @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 // Eliminate any chance that this blocking call might mess up the loading process
return withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
context.contentResolver.openInputStream(album.coverUri)
}
}
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}

View file

@ -26,5 +26,6 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface MetadataModule { interface MetadataModule {
@Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor @Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor
@Binds fun tagWorkerFactory(taskFactory: TagWorkerImpl.Factory): TagWorker.Factory
@Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider @Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider
} }

View file

@ -17,20 +17,11 @@
package org.oxycblt.auxio.music.metadata 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.MetadataRetriever
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.AudioOnlyExtractors
import org.oxycblt.auxio.music.model.RawSong 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 * 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<RawSong>, completeSongs: Channel<RawSong>) suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>)
} }
class TagExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) : class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWorker.Factory) :
TagExtractor { TagExtractor {
override suspend fun consume( override suspend fun consume(
incompleteSongs: Channel<RawSong>, incompleteSongs: Channel<RawSong>,
@ -58,22 +49,22 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte
) { ) {
// We can parallelize MetadataRetriever Futures to work around it's speed issues, // We can parallelize MetadataRetriever Futures to work around it's speed issues,
// producing similar throughput's to other kinds of manual metadata extraction. // producing similar throughput's to other kinds of manual metadata extraction.
val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY) val tagWorkerPool: Array<TagWorker?> = arrayOfNulls(TASK_CAPACITY)
for (song in incompleteSongs) { for (incompleteRawSong in incompleteSongs) {
spin@ while (true) { spin@ while (true) {
for (i in taskPool.indices) { for (i in tagWorkerPool.indices) {
val task = taskPool[i] val worker = tagWorkerPool[i]
if (task != null) { if (worker != null) {
val finishedRawSong = task.get() val completeRawSong = worker.poll()
if (finishedRawSong != null) { if (completeRawSong != null) {
completeSongs.send(finishedRawSong) completeSongs.send(completeRawSong)
yield() yield()
} else { } else {
continue continue
} }
} }
taskPool[i] = Task(context, song) tagWorkerPool[i] = tagWorkerFactory.create(incompleteRawSong)
break@spin break@spin
} }
} }
@ -81,13 +72,13 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte
do { do {
var ongoingTasks = false var ongoingTasks = false
for (i in taskPool.indices) { for (i in tagWorkerPool.indices) {
val task = taskPool[i] val task = tagWorkerPool[i]
if (task != null) { if (task != null) {
val finishedRawSong = task.get() val completeRawSong = task.poll()
if (finishedRawSong != null) { if (completeRawSong != null) {
completeSongs.send(finishedRawSong) completeSongs.send(completeRawSong)
taskPool[i] = null tagWorkerPool[i] = null
yield() yield()
} else { } else {
ongoingTasks = true ongoingTasks = true
@ -103,221 +94,3 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte
const val TASK_CAPACITY = 8 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<String, List<String>>) {
// 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<String, List<String>>): 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<String, List<String>>) {
// 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 }
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<TrackGroupArray>) :
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<String, List<String>>) {
// 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<String, List<String>>): 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<String, List<String>>) {
// 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())))
}
}

View file

@ -17,9 +17,23 @@
package org.oxycblt.auxio.playback 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.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
@ -33,3 +47,27 @@ interface PlaybackModule {
fun stateManager(playbackManager: PlaybackStateManagerImpl): PlaybackStateManager fun stateManager(playbackManager: PlaybackStateManagerImpl): PlaybackStateManager
@Binds fun settings(playbackSettings: PlaybackSettingsImpl): PlaybackSettings @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))
}
}

View file

@ -36,7 +36,7 @@ import com.google.android.exoplayer2.audio.AudioCapabilities
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
import com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer import com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector 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 dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -44,7 +44,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.AudioOnlyExtractors
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
@ -85,6 +84,7 @@ class PlaybackService :
MusicRepository.Listener { MusicRepository.Listener {
// Player components // Player components
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
@Inject lateinit var mediaSourceFactory: MediaSource.Factory
@Inject lateinit var replayGainProcessor: ReplayGainAudioProcessor @Inject lateinit var replayGainProcessor: ReplayGainAudioProcessor
// System backend components // System backend components
@ -133,7 +133,7 @@ class PlaybackService :
player = player =
ExoPlayer.Builder(this, audioRenderer) ExoPlayer.Builder(this, audioRenderer)
.setMediaSourceFactory(DefaultMediaSourceFactory(this, AudioOnlyExtractors)) .setMediaSourceFactory(mediaSourceFactory)
// Enable automatic WakeLock support // Enable automatic WakeLock support
.setWakeMode(C.WAKE_MODE_LOCAL) .setWakeMode(C.WAKE_MODE_LOCAL)
.setAudioAttributes( .setAudioAttributes(