playback: inject extractors
Use dependency injection with the custom extractor setup Just looks better this way.
This commit is contained in:
parent
e4aa409cbc
commit
069a4c9511
9 changed files with 360 additions and 375 deletions
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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))
|
|
||||||
}
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
257
app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt
Normal file
257
app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt
Normal 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())))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in a new issue