music: cleanup implementation
Clean up the indexer implementation even further.
This commit is contained in:
parent
48289ef006
commit
f52fa7f338
9 changed files with 333 additions and 302 deletions
|
@ -22,8 +22,9 @@ package org.oxycblt.auxio.music
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.core.text.isDigitsOnly
|
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.music.indexer.id3v2GenreName
|
||||||
|
import org.oxycblt.auxio.music.indexer.withoutArticle
|
||||||
import org.oxycblt.auxio.ui.Item
|
import org.oxycblt.auxio.ui.Item
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
@ -250,258 +251,8 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
|
||||||
get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong()
|
get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong()
|
||||||
|
|
||||||
override val sortName: String?
|
override val sortName: String?
|
||||||
get() = rawName?.genreNameCompat
|
get() = rawName?.id3v2GenreName
|
||||||
|
|
||||||
override fun resolveName(context: Context) =
|
override fun resolveName(context: Context) =
|
||||||
rawName?.genreNameCompat ?: context.getString(R.string.def_genre)
|
rawName?.id3v2GenreName ?: context.getString(R.string.def_genre)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
|
|
||||||
* anglo-centric, but its mostly for MediaStore compat and hopefully shouldn't run with other
|
|
||||||
* languages.
|
|
||||||
*/
|
|
||||||
private val String.withoutArticle: String
|
|
||||||
get() {
|
|
||||||
if (length > 5 && startsWith("the ", ignoreCase = true)) {
|
|
||||||
return slice(4..lastIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (length > 4 && startsWith("an ", ignoreCase = true)) {
|
|
||||||
return slice(3..lastIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (length > 3 && startsWith("a ", ignoreCase = true)) {
|
|
||||||
return slice(2..lastIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the genre constant
|
|
||||||
* map that Auxio uses.
|
|
||||||
*/
|
|
||||||
private val String.genreNameCompat: String
|
|
||||||
get() {
|
|
||||||
if (isDigitsOnly()) {
|
|
||||||
// ID3v1, just parse as an integer
|
|
||||||
return genreConstantTable.getOrNull(toInt()) ?: this
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith('(') && endsWith(')')) {
|
|
||||||
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
|
|
||||||
// Any genres formatted as "(CHARS)" will be ignored.
|
|
||||||
val genreInt = substring(1 until lastIndex).toIntOrNull()
|
|
||||||
if (genreInt != null) {
|
|
||||||
return genreConstantTable.getOrNull(genreInt) ?: this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current name is fine.
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
|
|
||||||
*/
|
|
||||||
private val genreConstantTable =
|
|
||||||
arrayOf(
|
|
||||||
// ID3 Standard
|
|
||||||
"Blues",
|
|
||||||
"Classic Rock",
|
|
||||||
"Country",
|
|
||||||
"Dance",
|
|
||||||
"Disco",
|
|
||||||
"Funk",
|
|
||||||
"Grunge",
|
|
||||||
"Hip-Hop",
|
|
||||||
"Jazz",
|
|
||||||
"Metal",
|
|
||||||
"New Age",
|
|
||||||
"Oldies",
|
|
||||||
"Other",
|
|
||||||
"Pop",
|
|
||||||
"R&B",
|
|
||||||
"Rap",
|
|
||||||
"Reggae",
|
|
||||||
"Rock",
|
|
||||||
"Techno",
|
|
||||||
"Industrial",
|
|
||||||
"Alternative",
|
|
||||||
"Ska",
|
|
||||||
"Death Metal",
|
|
||||||
"Pranks",
|
|
||||||
"Soundtrack",
|
|
||||||
"Euro-Techno",
|
|
||||||
"Ambient",
|
|
||||||
"Trip-Hop",
|
|
||||||
"Vocal",
|
|
||||||
"Jazz+Funk",
|
|
||||||
"Fusion",
|
|
||||||
"Trance",
|
|
||||||
"Classical",
|
|
||||||
"Instrumental",
|
|
||||||
"Acid",
|
|
||||||
"House",
|
|
||||||
"Game",
|
|
||||||
"Sound Clip",
|
|
||||||
"Gospel",
|
|
||||||
"Noise",
|
|
||||||
"AlternRock",
|
|
||||||
"Bass",
|
|
||||||
"Soul",
|
|
||||||
"Punk",
|
|
||||||
"Space",
|
|
||||||
"Meditative",
|
|
||||||
"Instrumental Pop",
|
|
||||||
"Instrumental Rock",
|
|
||||||
"Ethnic",
|
|
||||||
"Gothic",
|
|
||||||
"Darkwave",
|
|
||||||
"Techno-Industrial",
|
|
||||||
"Electronic",
|
|
||||||
"Pop-Folk",
|
|
||||||
"Eurodance",
|
|
||||||
"Dream",
|
|
||||||
"Southern Rock",
|
|
||||||
"Comedy",
|
|
||||||
"Cult",
|
|
||||||
"Gangsta",
|
|
||||||
"Top 40",
|
|
||||||
"Christian Rap",
|
|
||||||
"Pop/Funk",
|
|
||||||
"Jungle",
|
|
||||||
"Native American",
|
|
||||||
"Cabaret",
|
|
||||||
"New Wave",
|
|
||||||
"Psychadelic",
|
|
||||||
"Rave",
|
|
||||||
"Showtunes",
|
|
||||||
"Trailer",
|
|
||||||
"Lo-Fi",
|
|
||||||
"Tribal",
|
|
||||||
"Acid Punk",
|
|
||||||
"Acid Jazz",
|
|
||||||
"Polka",
|
|
||||||
"Retro",
|
|
||||||
"Musical",
|
|
||||||
"Rock & Roll",
|
|
||||||
"Hard Rock",
|
|
||||||
|
|
||||||
// Winamp extensions, more or less a de-facto standard
|
|
||||||
"Folk",
|
|
||||||
"Folk-Rock",
|
|
||||||
"National Folk",
|
|
||||||
"Swing",
|
|
||||||
"Fast Fusion",
|
|
||||||
"Bebob",
|
|
||||||
"Latin",
|
|
||||||
"Revival",
|
|
||||||
"Celtic",
|
|
||||||
"Bluegrass",
|
|
||||||
"Avantgarde",
|
|
||||||
"Gothic Rock",
|
|
||||||
"Progressive Rock",
|
|
||||||
"Psychedelic Rock",
|
|
||||||
"Symphonic Rock",
|
|
||||||
"Slow Rock",
|
|
||||||
"Big Band",
|
|
||||||
"Chorus",
|
|
||||||
"Easy Listening",
|
|
||||||
"Acoustic",
|
|
||||||
"Humour",
|
|
||||||
"Speech",
|
|
||||||
"Chanson",
|
|
||||||
"Opera",
|
|
||||||
"Chamber Music",
|
|
||||||
"Sonata",
|
|
||||||
"Symphony",
|
|
||||||
"Booty Bass",
|
|
||||||
"Primus",
|
|
||||||
"Porn Groove",
|
|
||||||
"Satire",
|
|
||||||
"Slow Jam",
|
|
||||||
"Club",
|
|
||||||
"Tango",
|
|
||||||
"Samba",
|
|
||||||
"Folklore",
|
|
||||||
"Ballad",
|
|
||||||
"Power Ballad",
|
|
||||||
"Rhythmic Soul",
|
|
||||||
"Freestyle",
|
|
||||||
"Duet",
|
|
||||||
"Punk Rock",
|
|
||||||
"Drum Solo",
|
|
||||||
"A capella",
|
|
||||||
"Euro-House",
|
|
||||||
"Dance Hall",
|
|
||||||
"Goa",
|
|
||||||
"Drum & Bass",
|
|
||||||
"Club-House",
|
|
||||||
"Hardcore",
|
|
||||||
"Terror",
|
|
||||||
"Indie",
|
|
||||||
"Britpop",
|
|
||||||
"Negerpunk",
|
|
||||||
"Polsk Punk",
|
|
||||||
"Beat",
|
|
||||||
"Christian Gangsta",
|
|
||||||
"Heavy Metal",
|
|
||||||
"Black Metal",
|
|
||||||
"Crossover",
|
|
||||||
"Contemporary Christian",
|
|
||||||
"Christian Rock",
|
|
||||||
"Merengue",
|
|
||||||
"Salsa",
|
|
||||||
"Thrash Metal",
|
|
||||||
"Anime",
|
|
||||||
"JPop",
|
|
||||||
"Synthpop",
|
|
||||||
|
|
||||||
// Winamp 5.6+ extensions, also used by EasyTAG.
|
|
||||||
// I only include this because post-rock is a based genre and deserves a slot.
|
|
||||||
"Abstract",
|
|
||||||
"Art Rock",
|
|
||||||
"Baroque",
|
|
||||||
"Bhangra",
|
|
||||||
"Big Beat",
|
|
||||||
"Breakbeat",
|
|
||||||
"Chillout",
|
|
||||||
"Downtempo",
|
|
||||||
"Dub",
|
|
||||||
"EBM",
|
|
||||||
"Eclectic",
|
|
||||||
"Electro",
|
|
||||||
"Electroclash",
|
|
||||||
"Emo",
|
|
||||||
"Experimental",
|
|
||||||
"Garage",
|
|
||||||
"Global",
|
|
||||||
"IDM",
|
|
||||||
"Illbient",
|
|
||||||
"Industro-Goth",
|
|
||||||
"Jam Band",
|
|
||||||
"Krautrock",
|
|
||||||
"Leftfield",
|
|
||||||
"Lounge",
|
|
||||||
"Math Rock",
|
|
||||||
"New Romantic",
|
|
||||||
"Nu-Breakz",
|
|
||||||
"Post-Punk",
|
|
||||||
"Post-Rock",
|
|
||||||
"Psytrance",
|
|
||||||
"Shoegaze",
|
|
||||||
"Space Rock",
|
|
||||||
"Trop Rock",
|
|
||||||
"World Music",
|
|
||||||
"Neoclassical",
|
|
||||||
"Audiobook",
|
|
||||||
"Audio Theatre",
|
|
||||||
"Neue Deutsche Welle",
|
|
||||||
"Podcast",
|
|
||||||
"Indie Rock",
|
|
||||||
"G-Funk",
|
|
||||||
"Dubstep",
|
|
||||||
"Garage Rock",
|
|
||||||
"Psybient")
|
|
||||||
|
|
|
@ -53,11 +53,17 @@ class MusicStore private constructor() {
|
||||||
|
|
||||||
private val callbacks = mutableListOf<Callback>()
|
private val callbacks = mutableListOf<Callback>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a callback to this instance. Make sure to remove it when done.
|
||||||
|
*/
|
||||||
fun addCallback(callback: Callback) {
|
fun addCallback(callback: Callback) {
|
||||||
response?.let(callback::onMusicUpdate)
|
response?.let(callback::onMusicUpdate)
|
||||||
callbacks.add(callback)
|
callbacks.add(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a callback from this instance.
|
||||||
|
*/
|
||||||
fun removeCallback(callback: Callback) {
|
fun removeCallback(callback: Callback) {
|
||||||
callbacks.remove(callback)
|
callbacks.remove(callback)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,8 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.indexer
|
package org.oxycblt.auxio.music.indexer
|
||||||
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.provider.MediaStore
|
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
import com.google.android.exoplayer2.MetadataRetriever
|
import com.google.android.exoplayer2.MetadataRetriever
|
||||||
import com.google.android.exoplayer2.metadata.Metadata
|
import com.google.android.exoplayer2.metadata.Metadata
|
||||||
|
@ -29,7 +27,6 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||||
import com.google.common.util.concurrent.FutureCallback
|
import com.google.common.util.concurrent.FutureCallback
|
||||||
import com.google.common.util.concurrent.Futures
|
import com.google.common.util.concurrent.Futures
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.Future
|
import java.util.concurrent.Future
|
||||||
|
@ -39,11 +36,20 @@ import org.oxycblt.auxio.util.logW
|
||||||
/**
|
/**
|
||||||
* A [Indexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata.
|
* A [Indexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata.
|
||||||
*
|
*
|
||||||
|
* Normally, leveraging ExoPlayer's metadata system would be a terrible idea, as it is horrifically
|
||||||
|
* slow. However, if we parallelize it, we can get similar throughput to other metadata extractors,
|
||||||
|
* which is nice as it means we don't have to bundle a redundant metadata library like JAudioTagger.
|
||||||
|
*
|
||||||
|
* Now, ExoPlayer's metadata API is not the best. It's opaque, undocumented, and prone to weird
|
||||||
|
* pitfalls given ExoPlayer's cozy relationship with native code. However, this backend should do
|
||||||
|
* enough to eliminate such issues.
|
||||||
|
*
|
||||||
* TODO: This class is currently not used, as there are a number of technical improvements that must
|
* TODO: This class is currently not used, as there are a number of technical improvements that must
|
||||||
* be made first before it is practical.
|
* be made first before it can be integrated.
|
||||||
*
|
*
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*/
|
*/
|
||||||
|
@Suppress("UNUSED")
|
||||||
class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
||||||
private val runningTasks: Array<Future<TrackGroupArray>?> = arrayOfNulls(TASK_CAPACITY)
|
private val runningTasks: Array<Future<TrackGroupArray>?> = arrayOfNulls(TASK_CAPACITY)
|
||||||
|
|
||||||
|
@ -58,21 +64,21 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val audio = inner.buildAudio(context, cursor)
|
val audio = inner.buildAudio(context, cursor)
|
||||||
|
|
||||||
val audioUri =
|
val audioUri = requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri
|
||||||
ContentUris.withAppendedId(
|
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, requireNotNull(audio.id))
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Spin until a task slot opens up, then start trying to parse metadata.
|
// Spin until a task slot opens up, then start trying to parse metadata.
|
||||||
val idx = runningTasks.indexOfFirst { it == null }
|
val index = runningTasks.indexOfFirst { it == null }
|
||||||
if (idx != -1) {
|
if (index != -1) {
|
||||||
val task =
|
val task =
|
||||||
MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(audioUri))
|
MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(audioUri))
|
||||||
|
|
||||||
Futures.addCallback(
|
Futures.addCallback(
|
||||||
task, AudioCallback(audio, songs, idx), Executors.newSingleThreadExecutor())
|
task,
|
||||||
|
AudioCallback(audio, songs, index),
|
||||||
|
Executors.newSingleThreadExecutor())
|
||||||
|
|
||||||
runningTasks[idx] = task
|
runningTasks[index] = task
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -89,7 +95,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
||||||
private inner class AudioCallback(
|
private inner class AudioCallback(
|
||||||
private val audio: MediaStoreBackend.Audio,
|
private val audio: MediaStoreBackend.Audio,
|
||||||
private val dest: ConcurrentLinkedQueue<Song>,
|
private val dest: ConcurrentLinkedQueue<Song>,
|
||||||
private val taskIdx: Int
|
private val taskIndex: Int
|
||||||
) : FutureCallback<TrackGroupArray> {
|
) : FutureCallback<TrackGroupArray> {
|
||||||
override fun onSuccess(result: TrackGroupArray) {
|
override fun onSuccess(result: TrackGroupArray) {
|
||||||
val metadata = result[0].getFormat(0).metadata
|
val metadata = result[0].getFormat(0).metadata
|
||||||
|
@ -100,30 +106,33 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
||||||
}
|
}
|
||||||
|
|
||||||
dest.add(audio.toSong())
|
dest.add(audio.toSong())
|
||||||
runningTasks[taskIdx] = null
|
runningTasks[taskIndex] = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(t: Throwable) {
|
override fun onFailure(t: Throwable) {
|
||||||
logW("Unable to extract metadata for ${audio.title}")
|
logW("Unable to extract metadata for ${audio.title}")
|
||||||
logW(t.stackTraceToString())
|
logW(t.stackTraceToString())
|
||||||
dest.add(audio.toSong())
|
dest.add(audio.toSong())
|
||||||
runningTasks[taskIdx] = null
|
runningTasks[taskIndex] = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun completeAudio(audio: MediaStoreBackend.Audio, metadata: Metadata) {
|
private fun completeAudio(audio: MediaStoreBackend.Audio, metadata: Metadata) {
|
||||||
for (i in 0 until metadata.length()) {
|
for (i in 0 until metadata.length()) {
|
||||||
|
// We only support two formats as it stands:
|
||||||
|
// - ID3v2 text frames
|
||||||
|
// - Vorbis comments
|
||||||
|
// This should be enough to cover the vast, vast majority of audio formats.
|
||||||
|
// It is also assumed that a file only has either ID3v2 text frames or vorbis
|
||||||
|
// comments.
|
||||||
when (val tag = metadata.get(i)) {
|
when (val tag = metadata.get(i)) {
|
||||||
// ID3v2 text information frame.
|
|
||||||
is TextInformationFrame ->
|
is TextInformationFrame ->
|
||||||
if (tag.value.isNotEmpty()) {
|
if (tag.value.isNotEmpty()) {
|
||||||
handleId3TextFrame(tag.id, tag.value.sanitize(), audio)
|
handleId3v2TextFrame(tag.id.sanitize(), tag.value.sanitize(), audio)
|
||||||
}
|
}
|
||||||
// Vorbis comment. It is assumed that a Metadata can only have vorbis
|
|
||||||
// comments/pictures or ID3 frames, never both.
|
|
||||||
is VorbisComment ->
|
is VorbisComment ->
|
||||||
if (tag.value.isNotEmpty()) {
|
if (tag.value.isNotEmpty()) {
|
||||||
handleVorbisComment(tag.key, tag.value.sanitize(), audio)
|
handleVorbisComment(tag.key.sanitize(), tag.value.sanitize(), audio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -138,12 +147,12 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
||||||
*
|
*
|
||||||
* This function mitigates it by first encoding the string as UTF-8 bytes (replacing malformed
|
* This function mitigates it by first encoding the string as UTF-8 bytes (replacing malformed
|
||||||
* characters with the replacement in the process), and then re-interpreting it as a new string,
|
* characters with the replacement in the process), and then re-interpreting it as a new string,
|
||||||
* which hopefully fixes encoding sanity while also copying the string out of dodgy native
|
* which hopefully fixes encoding insanity while also copying the string out of dodgy native
|
||||||
* memory.
|
* memory.
|
||||||
*/
|
*/
|
||||||
private fun String.sanitize() = String(encodeToByteArray(), StandardCharsets.UTF_8)
|
private fun String.sanitize() = String(encodeToByteArray())
|
||||||
|
|
||||||
private fun handleId3TextFrame(id: String, value: String, audio: MediaStoreBackend.Audio) {
|
private fun handleId3v2TextFrame(id: String, value: String, audio: MediaStoreBackend.Audio) {
|
||||||
// It's assumed that duplicate frames are eliminated by ExoPlayer's metadata parser.
|
// It's assumed that duplicate frames are eliminated by ExoPlayer's metadata parser.
|
||||||
when (id) {
|
when (id) {
|
||||||
"TIT2" -> audio.title = value // Title
|
"TIT2" -> audio.title = value // Title
|
||||||
|
|
|
@ -66,6 +66,7 @@ object Indexer {
|
||||||
if (songs.isEmpty()) return null
|
if (songs.isEmpty()) return null
|
||||||
|
|
||||||
val buildStart = System.currentTimeMillis()
|
val buildStart = System.currentTimeMillis()
|
||||||
|
|
||||||
val albums = buildAlbums(songs)
|
val albums = buildAlbums(songs)
|
||||||
val artists = buildArtists(albums)
|
val artists = buildArtists(albums)
|
||||||
val genres = buildGenres(songs)
|
val genres = buildGenres(songs)
|
||||||
|
@ -206,7 +207,7 @@ object Indexer {
|
||||||
|
|
||||||
/** Represents a backend that metadata can be extracted from. */
|
/** Represents a backend that metadata can be extracted from. */
|
||||||
interface Backend {
|
interface Backend {
|
||||||
/** Query the media database for an initial cursor. */
|
/** Query the media database for a basic cursor. */
|
||||||
fun query(context: Context): Cursor
|
fun query(context: Context): Cursor
|
||||||
|
|
||||||
/** Create a list of songs from the [Cursor] queried in [query]. */
|
/** Create a list of songs from the [Cursor] queried in [query]. */
|
||||||
|
|
|
@ -18,8 +18,11 @@
|
||||||
package org.oxycblt.auxio.music.indexer
|
package org.oxycblt.auxio.music.indexer
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.core.text.isDigitsOnly
|
||||||
|
|
||||||
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
|
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
|
||||||
fun ContentResolver.queryCursor(
|
fun ContentResolver.queryCursor(
|
||||||
|
@ -38,6 +41,12 @@ fun <R> ContentResolver.useQuery(
|
||||||
block: (Cursor) -> R
|
block: (Cursor) -> R
|
||||||
): R? = queryCursor(uri, projection, selector, args)?.use(block)
|
): R? = queryCursor(uri, projection, selector, args)?.use(block)
|
||||||
|
|
||||||
|
/** Converts a [Long] Audio ID into a URI to that particular audio file. */
|
||||||
|
val Long.audioUri: Uri
|
||||||
|
get() =
|
||||||
|
ContentUris.withAppendedId(
|
||||||
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, requireNotNull(this))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
|
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
|
||||||
* CD_TRACK_NUMBER.
|
* CD_TRACK_NUMBER.
|
||||||
|
@ -45,6 +54,265 @@ fun <R> ContentResolver.useQuery(
|
||||||
val String.no: Int?
|
val String.no: Int?
|
||||||
get() = split('/', limit = 2).getOrNull(0)?.toIntOrNull()
|
get() = split('/', limit = 2).getOrNull(0)?.toIntOrNull()
|
||||||
|
|
||||||
/** Parse out the year field from a (presumably) ISO-8601-like date. */
|
/**
|
||||||
|
* Parse out the year field from a (presumably) ISO-8601-like date. This differs across tag formats
|
||||||
|
* and has no real consistent, but it's assumed that most will format granular dates as YYYY-MM-DD
|
||||||
|
* (...) and thus we can parse the year out by splitting at the first -.
|
||||||
|
*/
|
||||||
val String.iso8601year: Int?
|
val String.iso8601year: Int?
|
||||||
get() = split(":", limit = 2).getOrNull(0)?.toIntOrNull()
|
get() = split('-', limit = 2).getOrNull(0)?.toIntOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
|
||||||
|
* anglo-centric, but it's also a bit of an expected feature in music players, so we implement it
|
||||||
|
* anyway.
|
||||||
|
*/
|
||||||
|
val String.withoutArticle: String
|
||||||
|
get() {
|
||||||
|
if (length > 5 && startsWith("the ", ignoreCase = true)) {
|
||||||
|
return slice(4..lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length > 4 && startsWith("an ", ignoreCase = true)) {
|
||||||
|
return slice(3..lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length > 3 && startsWith("a ", ignoreCase = true)) {
|
||||||
|
return slice(2..lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the genre constant
|
||||||
|
* map that Auxio uses.
|
||||||
|
*/
|
||||||
|
val String.id3v2GenreName: String
|
||||||
|
get() {
|
||||||
|
if (isDigitsOnly()) {
|
||||||
|
// ID3v1, just parse as an integer
|
||||||
|
return genreConstantTable.getOrNull(toInt()) ?: this
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startsWith('(') && endsWith(')')) {
|
||||||
|
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
|
||||||
|
// Any genres formatted as "(CHARS)" will be ignored.
|
||||||
|
|
||||||
|
// TODO: Technically, the spec for genres is far more complex here. Perhaps we
|
||||||
|
// should copy mutagen's implementation?
|
||||||
|
// https://github.com/quodlibet/mutagen/blob/master/mutagen/id3/_frames.py
|
||||||
|
|
||||||
|
val genreInt = substring(1 until lastIndex).toIntOrNull()
|
||||||
|
if (genreInt != null) {
|
||||||
|
return genreConstantTable.getOrNull(genreInt) ?: this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current name is fine.
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
|
||||||
|
*/
|
||||||
|
private val genreConstantTable =
|
||||||
|
arrayOf(
|
||||||
|
// ID3 Standard
|
||||||
|
"Blues",
|
||||||
|
"Classic Rock",
|
||||||
|
"Country",
|
||||||
|
"Dance",
|
||||||
|
"Disco",
|
||||||
|
"Funk",
|
||||||
|
"Grunge",
|
||||||
|
"Hip-Hop",
|
||||||
|
"Jazz",
|
||||||
|
"Metal",
|
||||||
|
"New Age",
|
||||||
|
"Oldies",
|
||||||
|
"Other",
|
||||||
|
"Pop",
|
||||||
|
"R&B",
|
||||||
|
"Rap",
|
||||||
|
"Reggae",
|
||||||
|
"Rock",
|
||||||
|
"Techno",
|
||||||
|
"Industrial",
|
||||||
|
"Alternative",
|
||||||
|
"Ska",
|
||||||
|
"Death Metal",
|
||||||
|
"Pranks",
|
||||||
|
"Soundtrack",
|
||||||
|
"Euro-Techno",
|
||||||
|
"Ambient",
|
||||||
|
"Trip-Hop",
|
||||||
|
"Vocal",
|
||||||
|
"Jazz+Funk",
|
||||||
|
"Fusion",
|
||||||
|
"Trance",
|
||||||
|
"Classical",
|
||||||
|
"Instrumental",
|
||||||
|
"Acid",
|
||||||
|
"House",
|
||||||
|
"Game",
|
||||||
|
"Sound Clip",
|
||||||
|
"Gospel",
|
||||||
|
"Noise",
|
||||||
|
"AlternRock",
|
||||||
|
"Bass",
|
||||||
|
"Soul",
|
||||||
|
"Punk",
|
||||||
|
"Space",
|
||||||
|
"Meditative",
|
||||||
|
"Instrumental Pop",
|
||||||
|
"Instrumental Rock",
|
||||||
|
"Ethnic",
|
||||||
|
"Gothic",
|
||||||
|
"Darkwave",
|
||||||
|
"Techno-Industrial",
|
||||||
|
"Electronic",
|
||||||
|
"Pop-Folk",
|
||||||
|
"Eurodance",
|
||||||
|
"Dream",
|
||||||
|
"Southern Rock",
|
||||||
|
"Comedy",
|
||||||
|
"Cult",
|
||||||
|
"Gangsta",
|
||||||
|
"Top 40",
|
||||||
|
"Christian Rap",
|
||||||
|
"Pop/Funk",
|
||||||
|
"Jungle",
|
||||||
|
"Native American",
|
||||||
|
"Cabaret",
|
||||||
|
"New Wave",
|
||||||
|
"Psychadelic",
|
||||||
|
"Rave",
|
||||||
|
"Showtunes",
|
||||||
|
"Trailer",
|
||||||
|
"Lo-Fi",
|
||||||
|
"Tribal",
|
||||||
|
"Acid Punk",
|
||||||
|
"Acid Jazz",
|
||||||
|
"Polka",
|
||||||
|
"Retro",
|
||||||
|
"Musical",
|
||||||
|
"Rock & Roll",
|
||||||
|
"Hard Rock",
|
||||||
|
|
||||||
|
// Winamp extensions, more or less a de-facto standard
|
||||||
|
"Folk",
|
||||||
|
"Folk-Rock",
|
||||||
|
"National Folk",
|
||||||
|
"Swing",
|
||||||
|
"Fast Fusion",
|
||||||
|
"Bebob",
|
||||||
|
"Latin",
|
||||||
|
"Revival",
|
||||||
|
"Celtic",
|
||||||
|
"Bluegrass",
|
||||||
|
"Avantgarde",
|
||||||
|
"Gothic Rock",
|
||||||
|
"Progressive Rock",
|
||||||
|
"Psychedelic Rock",
|
||||||
|
"Symphonic Rock",
|
||||||
|
"Slow Rock",
|
||||||
|
"Big Band",
|
||||||
|
"Chorus",
|
||||||
|
"Easy Listening",
|
||||||
|
"Acoustic",
|
||||||
|
"Humour",
|
||||||
|
"Speech",
|
||||||
|
"Chanson",
|
||||||
|
"Opera",
|
||||||
|
"Chamber Music",
|
||||||
|
"Sonata",
|
||||||
|
"Symphony",
|
||||||
|
"Booty Bass",
|
||||||
|
"Primus",
|
||||||
|
"Porn Groove",
|
||||||
|
"Satire",
|
||||||
|
"Slow Jam",
|
||||||
|
"Club",
|
||||||
|
"Tango",
|
||||||
|
"Samba",
|
||||||
|
"Folklore",
|
||||||
|
"Ballad",
|
||||||
|
"Power Ballad",
|
||||||
|
"Rhythmic Soul",
|
||||||
|
"Freestyle",
|
||||||
|
"Duet",
|
||||||
|
"Punk Rock",
|
||||||
|
"Drum Solo",
|
||||||
|
"A capella",
|
||||||
|
"Euro-House",
|
||||||
|
"Dance Hall",
|
||||||
|
"Goa",
|
||||||
|
"Drum & Bass",
|
||||||
|
"Club-House",
|
||||||
|
"Hardcore",
|
||||||
|
"Terror",
|
||||||
|
"Indie",
|
||||||
|
"Britpop",
|
||||||
|
"Negerpunk",
|
||||||
|
"Polsk Punk",
|
||||||
|
"Beat",
|
||||||
|
"Christian Gangsta",
|
||||||
|
"Heavy Metal",
|
||||||
|
"Black Metal",
|
||||||
|
"Crossover",
|
||||||
|
"Contemporary Christian",
|
||||||
|
"Christian Rock",
|
||||||
|
"Merengue",
|
||||||
|
"Salsa",
|
||||||
|
"Thrash Metal",
|
||||||
|
"Anime",
|
||||||
|
"JPop",
|
||||||
|
"Synthpop",
|
||||||
|
|
||||||
|
// Winamp 5.6+ extensions, also used by EasyTAG.
|
||||||
|
// I only include this because post-rock is a based genre and deserves a slot.
|
||||||
|
"Abstract",
|
||||||
|
"Art Rock",
|
||||||
|
"Baroque",
|
||||||
|
"Bhangra",
|
||||||
|
"Big Beat",
|
||||||
|
"Breakbeat",
|
||||||
|
"Chillout",
|
||||||
|
"Downtempo",
|
||||||
|
"Dub",
|
||||||
|
"EBM",
|
||||||
|
"Eclectic",
|
||||||
|
"Electro",
|
||||||
|
"Electroclash",
|
||||||
|
"Emo",
|
||||||
|
"Experimental",
|
||||||
|
"Garage",
|
||||||
|
"Global",
|
||||||
|
"IDM",
|
||||||
|
"Illbient",
|
||||||
|
"Industro-Goth",
|
||||||
|
"Jam Band",
|
||||||
|
"Krautrock",
|
||||||
|
"Leftfield",
|
||||||
|
"Lounge",
|
||||||
|
"Math Rock",
|
||||||
|
"New Romantic",
|
||||||
|
"Nu-Breakz",
|
||||||
|
"Post-Punk",
|
||||||
|
"Post-Rock",
|
||||||
|
"Psytrance",
|
||||||
|
"Shoegaze",
|
||||||
|
"Space Rock",
|
||||||
|
"Trop Rock",
|
||||||
|
"World Music",
|
||||||
|
"Neoclassical",
|
||||||
|
"Audiobook",
|
||||||
|
"Audio Theatre",
|
||||||
|
"Neue Deutsche Welle",
|
||||||
|
"Podcast",
|
||||||
|
"Indie Rock",
|
||||||
|
"G-Funk",
|
||||||
|
"Dubstep",
|
||||||
|
"Garage Rock",
|
||||||
|
"Psybient")
|
||||||
|
|
|
@ -73,14 +73,12 @@ import org.oxycblt.auxio.util.contentResolverSafe
|
||||||
* Is there anything we can do about it? No. Google has routinely shut down issues that begged
|
* Is there anything we can do about it? No. Google has routinely shut down issues that begged
|
||||||
* google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
|
* google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
|
||||||
* shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
|
* shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
|
||||||
* listening is. As a result, some players like Vanilla and VLC just hack their own
|
* listening is. As a result, Auxio exposes an option to use an internal parser based on ExoPlayer
|
||||||
* pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for
|
* that at least tries to correct the insane metadata that this API returns, but not only is that
|
||||||
* Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much
|
* system horrifically slow and bug-prone, it also faces the even larger issue of how google keeps
|
||||||
* harder it is to manage a database of your own media that mirrors the filesystem perfectly. And
|
* trying to kill the filesystem and force you into their ContentResolver API. In the future
|
||||||
* even if I set aside those crippling issues and changed my indexer to that, it would face the even
|
* MediaStore could be the only system we have, which is also the day that greenland melts and
|
||||||
* larger problem of how google keeps trying to kill the filesystem and force you into their
|
* birthdays stop happening forever.
|
||||||
* ContentResolver API. In the future MediaStore could be the only system we have, which is also the
|
|
||||||
* day that greenland melts and birthdays stop happening forever.
|
|
||||||
*
|
*
|
||||||
* I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
|
* I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
|
||||||
* probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
|
* probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
|
||||||
|
@ -181,7 +179,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The projection to use when querying media. Add version-specific columns here in our
|
* The projection to use when querying media. Add version-specific columns here in an
|
||||||
* implementation.
|
* implementation.
|
||||||
*/
|
*/
|
||||||
open val projection: Array<String>
|
open val projection: Array<String>
|
||||||
|
@ -230,8 +228,10 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
||||||
audio.album = cursor.getStringOrNull(albumIndex)
|
audio.album = cursor.getStringOrNull(albumIndex)
|
||||||
audio.albumId = cursor.getLong(albumIdIndex)
|
audio.albumId = cursor.getLong(albumIdIndex)
|
||||||
|
|
||||||
// If the artist field is <unknown>, make it null. This makes handling the
|
// Android does not make a non-existent artist tag null, it instead fills it in
|
||||||
// insanity of the artist field easier later on.
|
// as <unknown>, which makes absolutely no sense given how other fields default
|
||||||
|
// to null if they are not present. If this field is <unknown>, null it so that
|
||||||
|
// it's easier to handle later.
|
||||||
audio.artist =
|
audio.artist =
|
||||||
cursor.getStringOrNull(artistIndex)?.run {
|
cursor.getStringOrNull(artistIndex)?.run {
|
||||||
if (this != MediaStore.UNKNOWN_STRING) {
|
if (this != MediaStore.UNKNOWN_STRING) {
|
||||||
|
@ -267,21 +267,20 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
||||||
) {
|
) {
|
||||||
fun toSong(): Song =
|
fun toSong(): Song =
|
||||||
Song(
|
Song(
|
||||||
|
// Assert that the fields that should exist are present. I can't confirm that
|
||||||
|
// every device provides these fields, but it seems likely that they do.
|
||||||
rawName = requireNotNull(title) { "Malformed audio: No title" },
|
rawName = requireNotNull(title) { "Malformed audio: No title" },
|
||||||
fileName = requireNotNull(displayName) { "Malformed audio: No file name" },
|
fileName = requireNotNull(displayName) { "Malformed audio: No file name" },
|
||||||
uri =
|
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
|
||||||
ContentUris.withAppendedId(
|
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
|
||||||
requireNotNull(id) { "Malformed audio: No song id" }),
|
|
||||||
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
|
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
|
||||||
track = track,
|
track = track,
|
||||||
disc = disc,
|
disc = disc,
|
||||||
_year = year,
|
_year = year,
|
||||||
_albumName = requireNotNull(album) { "Malformed song: No album name" },
|
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
|
||||||
_albumCoverUri =
|
_albumCoverUri =
|
||||||
ContentUris.withAppendedId(
|
ContentUris.withAppendedId(
|
||||||
EXTERNAL_ALBUM_ART_URI,
|
EXTERNAL_ALBUM_ART_URI,
|
||||||
requireNotNull(albumId) { "Malformed song: No album id" }),
|
requireNotNull(albumId) { "Malformed audio: No album id" }),
|
||||||
_artistName = artist,
|
_artistName = artist,
|
||||||
_albumArtistName = albumArtist,
|
_albumArtistName = albumArtist,
|
||||||
_genreName = genre)
|
_genreName = genre)
|
||||||
|
|
|
@ -40,6 +40,8 @@ import org.oxycblt.auxio.util.textSafe
|
||||||
* Instead, we wrap it in a safe class that hopefully implements enough safety to not crash the app
|
* Instead, we wrap it in a safe class that hopefully implements enough safety to not crash the app
|
||||||
* or result in blatantly janky behavior. Mostly.
|
* or result in blatantly janky behavior. Mostly.
|
||||||
*
|
*
|
||||||
|
* TODO: Add smooth seeking
|
||||||
|
*
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*/
|
*/
|
||||||
class StyledSeekBar
|
class StyledSeekBar
|
||||||
|
|
|
@ -71,8 +71,7 @@ class PlaybackStateManager private constructor() {
|
||||||
notifyPlayingChanged()
|
notifyPlayingChanged()
|
||||||
}
|
}
|
||||||
/** The current playback progress */
|
/** The current playback progress */
|
||||||
var positionMs = 0L
|
private var positionMs = 0L
|
||||||
private set
|
|
||||||
/** The current [RepeatMode] */
|
/** The current [RepeatMode] */
|
||||||
var repeatMode = RepeatMode.NONE
|
var repeatMode = RepeatMode.NONE
|
||||||
set(value) {
|
set(value) {
|
||||||
|
@ -92,8 +91,7 @@ class PlaybackStateManager private constructor() {
|
||||||
private val callbacks = mutableListOf<Callback>()
|
private val callbacks = mutableListOf<Callback>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a [PlaybackStateManager.Callback] to this instance. Make sure to remove the callback with
|
* Add a callback to this instance. Make sure to remove it when done.
|
||||||
* [removeCallback] when done.
|
|
||||||
*/
|
*/
|
||||||
fun addCallback(callback: Callback) {
|
fun addCallback(callback: Callback) {
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
|
|
|
@ -44,9 +44,6 @@ fun <T> unlikelyToBeNull(value: T?): T {
|
||||||
/** Shortcut to clamp an integer between [min] and [max] */
|
/** Shortcut to clamp an integer between [min] and [max] */
|
||||||
fun Int.clamp(min: Int, max: Int): Int = MathUtils.clamp(this, min, max)
|
fun Int.clamp(min: Int, max: Int): Int = MathUtils.clamp(this, min, max)
|
||||||
|
|
||||||
/** Shortcut to clamp an integer between [min] and [max] */
|
|
||||||
fun Long.clamp(min: Long, max: Long): Long = MathUtils.clamp(this, min, max)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a [Long] of seconds into a string duration.
|
* Convert a [Long] of seconds into a string duration.
|
||||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||||
|
|
Loading…
Reference in a new issue