music: restructure around raw objects
Restructure music data around a series of "raw" objects that better hide the internal information needed to properly construct the library.
This commit is contained in:
parent
c9422b7f9d
commit
022f92f27f
5 changed files with 263 additions and 283 deletions
|
@ -20,7 +20,6 @@
|
|||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
|
@ -64,47 +63,11 @@ sealed class MusicParent : Music() {
|
|||
abstract val songs: List<Song>
|
||||
}
|
||||
|
||||
/** The data object for a song. */
|
||||
data class Song(
|
||||
override val rawName: String,
|
||||
override val rawSortName: String?,
|
||||
/** The path of this song. */
|
||||
val path: Path,
|
||||
/** The URI linking to this song's file. */
|
||||
val uri: Uri,
|
||||
/** The mime type of this song. */
|
||||
val mimeType: MimeType,
|
||||
/** The size of this song (in bytes) */
|
||||
val size: Long,
|
||||
/** The datetime at which this media item was added, represented as a unix timestamp. */
|
||||
val dateAdded: Long,
|
||||
/** The total duration of this song, in millis. */
|
||||
val durationMs: Long,
|
||||
/** The track number of this song, null if there isn't any. */
|
||||
val track: Int?,
|
||||
/** The disc number of this song, null if there isn't any. */
|
||||
val disc: Int?,
|
||||
/** Internal field. Do not use. */
|
||||
val _date: Date?,
|
||||
/** Internal field. Do not use. */
|
||||
val _albumName: String,
|
||||
/** Internal field. Do not use. */
|
||||
val _albumSortName: String?,
|
||||
/** Internal field. Do not use. */
|
||||
val _albumReleaseType: ReleaseType?,
|
||||
/** Internal field. Do not use. */
|
||||
val _albumCoverUri: Uri,
|
||||
/** Internal field. Do not use. */
|
||||
val _artistName: String?,
|
||||
/** Internal field. Do not use. */
|
||||
val _artistSortName: String?,
|
||||
/** Internal field. Do not use. */
|
||||
val _albumArtistName: String?,
|
||||
/** Internal field. Do not use. */
|
||||
val _albumArtistSortName: String?,
|
||||
/** Internal field. Do not use. */
|
||||
val _genreName: String?
|
||||
) : Music() {
|
||||
/**
|
||||
* A song.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
data class Song(private val raw: Raw) : Music() {
|
||||
override val id: Long
|
||||
get() {
|
||||
var result = rawName.toMusicId()
|
||||
|
@ -116,8 +79,45 @@ data class Song(
|
|||
return result
|
||||
}
|
||||
|
||||
override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" }
|
||||
|
||||
override val rawSortName = raw.sortName
|
||||
|
||||
override fun resolveName(context: Context) = rawName
|
||||
|
||||
/** The URI pointing towards this audio file. */
|
||||
val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.audioUri
|
||||
|
||||
/**
|
||||
* The path component of the audio file for this music. Only intended for display. Use [uri] to
|
||||
* open the audio file.
|
||||
*/
|
||||
val path =
|
||||
Path(
|
||||
name = requireNotNull(raw.displayName) { "Invalid raw: No display name" },
|
||||
parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" })
|
||||
|
||||
/** The mime type of the audio file. Only intended for display. */
|
||||
val mimeType =
|
||||
MimeType(
|
||||
fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" },
|
||||
fromFormat = raw.formatMimeType)
|
||||
|
||||
/** The size of this audio file. */
|
||||
val size = requireNotNull(raw.size) { "Invalid raw: No size" }
|
||||
|
||||
/** The duration of this audio file, in millis. */
|
||||
val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" }
|
||||
|
||||
/** The date this audio file was added, as a unix epoch timestamp. */
|
||||
val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" }
|
||||
|
||||
/** The track number of this song in it's album.. */
|
||||
val track = raw.track
|
||||
|
||||
/** The disc number of this song in it's album. */
|
||||
val disc = raw.disc
|
||||
|
||||
private var _album: Album? = null
|
||||
/** The album of this song. */
|
||||
val album: Album
|
||||
|
@ -133,78 +133,99 @@ data class Song(
|
|||
* back to the album artist tag (i.e parent artist name). Null if name is unknown.
|
||||
*/
|
||||
val individualArtistRawName: String?
|
||||
get() = _artistName ?: album.artist.rawName
|
||||
get() = raw.artistName ?: album.artist.rawName
|
||||
|
||||
/**
|
||||
* Resolve the artist name for this song in particular. First uses the artist tag, and then
|
||||
* falls back to the album artist tag (i.e parent artist name)
|
||||
*/
|
||||
fun resolveIndividualArtistName(context: Context) =
|
||||
_artistName ?: album.artist.resolveName(context)
|
||||
raw.artistName ?: album.artist.resolveName(context)
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val _albumGroupingId: Long
|
||||
get() {
|
||||
var result = _artistGroupingName.toMusicId()
|
||||
result = 31 * result + _albumName.toMusicId()
|
||||
return result
|
||||
}
|
||||
// --- INTERNAL FIELDS ---
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val _genreGroupingId: Long
|
||||
get() = _genreName.toMusicId()
|
||||
val _distinct =
|
||||
rawName to
|
||||
raw.albumName to
|
||||
raw.artistName to
|
||||
raw.albumArtistName to
|
||||
raw.genreName to
|
||||
track to
|
||||
disc to
|
||||
durationMs
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val _artistGroupingName: String?
|
||||
get() = _albumArtistName ?: _artistName
|
||||
val _rawAlbum: Album.Raw
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val _artistGroupingSortName: String?
|
||||
get() =
|
||||
when {
|
||||
_albumArtistName != null -> _albumArtistSortName
|
||||
_artistName != null -> _artistSortName
|
||||
else -> null
|
||||
}
|
||||
val _rawGenre = Genre.Raw(raw.genreName)
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val _isMissingAlbum: Boolean
|
||||
get() = _album == null
|
||||
/** Internal field. Do not use. */
|
||||
|
||||
val _isMissingArtist: Boolean
|
||||
get() = _album?._isMissingArtist ?: true
|
||||
/** Internal field. Do not use. */
|
||||
|
||||
val _isMissingGenre: Boolean
|
||||
get() = _genre == null
|
||||
|
||||
/** Internal method. Do not use. */
|
||||
fun _link(album: Album) {
|
||||
_album = album
|
||||
}
|
||||
|
||||
/** Internal method. Do not use. */
|
||||
fun _link(genre: Genre) {
|
||||
_genre = genre
|
||||
}
|
||||
|
||||
init {
|
||||
val artistName: String?
|
||||
val artistSortName: String?
|
||||
|
||||
if (raw.albumArtistName != null) {
|
||||
artistName = raw.albumArtistName
|
||||
artistSortName = raw.albumArtistSortName
|
||||
} else {
|
||||
artistName = raw.artistName
|
||||
artistSortName = raw.artistSortName
|
||||
}
|
||||
|
||||
_rawAlbum =
|
||||
Album.Raw(
|
||||
mediaStoreId = raw.albumMediaStoreId,
|
||||
name = raw.albumName,
|
||||
sortName = raw.albumSortName,
|
||||
date = raw.date,
|
||||
releaseType = raw.albumReleaseType,
|
||||
artistName,
|
||||
artistSortName)
|
||||
}
|
||||
|
||||
data class Raw(
|
||||
var mediaStoreId: Long? = null,
|
||||
var name: String? = null,
|
||||
var sortName: String? = null,
|
||||
var displayName: String? = null,
|
||||
var directory: Directory? = null,
|
||||
var extensionMimeType: String? = null,
|
||||
var formatMimeType: String? = null,
|
||||
var size: Long? = null,
|
||||
var dateAdded: Long? = null,
|
||||
var durationMs: Long? = null,
|
||||
var track: Int? = null,
|
||||
var disc: Int? = null,
|
||||
var date: Date? = null,
|
||||
var albumMediaStoreId: Long? = null,
|
||||
var albumName: String? = null,
|
||||
var albumSortName: String? = null,
|
||||
var albumReleaseType: ReleaseType? = null,
|
||||
var artistName: String? = null,
|
||||
var artistSortName: String? = null,
|
||||
var albumArtistName: String? = null,
|
||||
var albumArtistSortName: String? = null,
|
||||
var genreName: String? = null
|
||||
)
|
||||
}
|
||||
|
||||
/** The data object for an album. */
|
||||
data class Album(
|
||||
override val rawName: String,
|
||||
override val rawSortName: String?,
|
||||
/** The date this album was released. */
|
||||
val date: Date?,
|
||||
/** The type of release this album has. */
|
||||
val releaseType: ReleaseType,
|
||||
/** The URI for the cover image corresponding to this album. */
|
||||
val coverUri: Uri,
|
||||
/** The songs of this album. */
|
||||
override val songs: List<Song>,
|
||||
/** Internal field. Do not use. */
|
||||
val _artistGroupingName: String?,
|
||||
/** Internal field. Do not use. */
|
||||
val _artistGroupingSortName: String?
|
||||
) : MusicParent() {
|
||||
data class Album(private val raw: Raw, override val songs: List<Song>) : MusicParent() {
|
||||
init {
|
||||
for (song in songs) {
|
||||
song._link(this)
|
||||
|
@ -219,8 +240,24 @@ data class Album(
|
|||
return result
|
||||
}
|
||||
|
||||
override val rawName = requireNotNull(raw.name) { "Invalid raw: No name" }
|
||||
|
||||
override val rawSortName = raw.sortName
|
||||
|
||||
override fun resolveName(context: Context) = rawName
|
||||
|
||||
/**
|
||||
* The album cover URI for this album. Usually low quality, so using Coil is recommended
|
||||
* instead.
|
||||
*/
|
||||
val coverUri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.albumCoverUri
|
||||
|
||||
/** The latest date this album was released. */
|
||||
val date = raw.date
|
||||
|
||||
/** The release type of this album, such as "EP". Defaults to "Album". */
|
||||
val releaseType = raw.releaseType ?: ReleaseType.Album(null)
|
||||
|
||||
private var _artist: Artist? = null
|
||||
/** The parent artist of this album. */
|
||||
val artist: Artist
|
||||
|
@ -229,21 +266,38 @@ data class Album(
|
|||
/** The earliest date a song in this album was added. */
|
||||
val dateAdded = songs.minOf { it.dateAdded }
|
||||
|
||||
val durationMs: Long
|
||||
get() = songs.sumOf { it.durationMs }
|
||||
/** The total duration of songs in this album, in millis. */
|
||||
val durationMs = songs.sumOf { it.durationMs }
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val _artistGroupingId: Long
|
||||
get() = _artistGroupingName.toMusicId()
|
||||
// --- INTERNAL FIELDS ---
|
||||
|
||||
val _rawArtist: Artist.Raw
|
||||
get() = Artist.Raw(name = raw.artistName, sortName = raw.artistSortName)
|
||||
|
||||
/** Internal field. Do not use. */
|
||||
val _isMissingArtist: Boolean
|
||||
get() = _artist == null
|
||||
|
||||
/** Internal method. Do not use. */
|
||||
fun _link(artist: Artist) {
|
||||
_artist = artist
|
||||
}
|
||||
|
||||
data class Raw(
|
||||
val mediaStoreId: Long?,
|
||||
val name: String?,
|
||||
val sortName: String?,
|
||||
val date: Date?,
|
||||
val releaseType: ReleaseType?,
|
||||
val artistName: String?,
|
||||
val artistSortName: String?,
|
||||
) {
|
||||
val groupingId: Long
|
||||
|
||||
init {
|
||||
var groupingIdResult = artistName.toMusicId()
|
||||
groupingIdResult = 31 * groupingIdResult + name.toMusicId()
|
||||
groupingId = groupingIdResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -251,8 +305,7 @@ data class Album(
|
|||
* artist or artist field, not the individual performers of an artist.
|
||||
*/
|
||||
data class Artist(
|
||||
override val rawName: String?,
|
||||
override val rawSortName: String?,
|
||||
private val raw: Raw,
|
||||
/** The albums of this artist. */
|
||||
val albums: List<Album>
|
||||
) : MusicParent() {
|
||||
|
@ -265,23 +318,33 @@ data class Artist(
|
|||
override val id: Long
|
||||
get() = rawName.toMusicId()
|
||||
|
||||
override val rawName = raw.name
|
||||
|
||||
override val rawSortName = raw.sortName
|
||||
|
||||
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
|
||||
|
||||
/** The songs of this artist. */
|
||||
override val songs = albums.flatMap { it.songs }
|
||||
|
||||
val durationMs: Long
|
||||
get() = songs.sumOf { it.durationMs }
|
||||
/** The total duration of songs in this artist, in millis. */
|
||||
val durationMs = songs.sumOf { it.durationMs }
|
||||
|
||||
data class Raw(val name: String?, val sortName: String?) {
|
||||
val groupingId = name.toMusicId()
|
||||
}
|
||||
}
|
||||
|
||||
/** The data object for a genre. */
|
||||
data class Genre(override val rawName: String?, override val songs: List<Song>) : MusicParent() {
|
||||
data class Genre(private val raw: Raw, override val songs: List<Song>) : MusicParent() {
|
||||
init {
|
||||
for (song in songs) {
|
||||
song._link(this)
|
||||
}
|
||||
}
|
||||
|
||||
override val rawName: String?
|
||||
get() = raw.name
|
||||
|
||||
// Sort tags don't make sense on genres
|
||||
override val rawSortName: String?
|
||||
get() = rawName
|
||||
|
@ -291,8 +354,12 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
|
|||
|
||||
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
|
||||
|
||||
val durationMs: Long
|
||||
get() = songs.sumOf { it.durationMs }
|
||||
/** The total duration of the songs in this genre, in millis. */
|
||||
val durationMs = songs.sumOf { it.durationMs }
|
||||
|
||||
data class Raw(val name: String?) {
|
||||
val groupingId: Long = name.toMusicId()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.toMusicId(): Long {
|
||||
|
|
|
@ -67,13 +67,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
|||
val songs = mutableListOf<Song>()
|
||||
val total = cursor.count
|
||||
|
||||
// LEFTOFF: Make logic more consistent?
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
// Note: This call to buildAudio does not populate the genre field. This is
|
||||
// because indexing genres is quite slow with MediaStore, and so keeping the
|
||||
// field blank on unsupported ExoPlayer formats ends up being preferable.
|
||||
val audio = inner.buildAudio(context, cursor)
|
||||
val raw = inner.buildRawSong(context, cursor)
|
||||
|
||||
// Spin until there is an open slot we can insert a task in. Note that we do
|
||||
// not add callbacks to our new tasks, as Future callbacks run on a different
|
||||
|
@ -88,11 +86,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
|||
if (song != null) {
|
||||
songs.add(song)
|
||||
emitIndexing(Indexer.Indexing.Songs(songs.size, total))
|
||||
runningTasks[i] = Task(context, audio)
|
||||
runningTasks[i] = Task(context, raw)
|
||||
break@spin
|
||||
}
|
||||
} else {
|
||||
runningTasks[i] = Task(context, audio)
|
||||
runningTasks[i] = Task(context, raw)
|
||||
break@spin
|
||||
}
|
||||
}
|
||||
|
@ -128,11 +126,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
|||
* Wraps an ExoPlayer metadata retrieval task in a safe abstraction. Access is done with [get].
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
||||
class Task(context: Context, private val raw: Song.Raw) {
|
||||
private val future =
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
context,
|
||||
MediaItem.fromUri(requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri))
|
||||
MediaItem.fromUri(requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.audioUri))
|
||||
|
||||
/**
|
||||
* Get the song that this task is trying to complete. If the task is still busy, this will
|
||||
|
@ -147,27 +145,27 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
|||
try {
|
||||
future.get()[0].getFormat(0)
|
||||
} catch (e: Exception) {
|
||||
logW("Unable to extract metadata for ${audio.title}")
|
||||
logW("Unable to extract metadata for ${raw.name}")
|
||||
logW(e.stackTraceToString())
|
||||
null
|
||||
}
|
||||
|
||||
if (format == null) {
|
||||
logD("Nothing could be extracted for ${audio.title}")
|
||||
return audio.toSong()
|
||||
logD("Nothing could be extracted for ${raw.name}")
|
||||
return Song(raw)
|
||||
}
|
||||
|
||||
// Populate the format mime type if we have one.
|
||||
format.sampleMimeType?.let { audio.formatMimeType = it }
|
||||
format.sampleMimeType?.let { raw.formatMimeType = it }
|
||||
|
||||
val metadata = format.metadata
|
||||
if (metadata != null) {
|
||||
completeAudio(metadata)
|
||||
} else {
|
||||
logD("No metadata could be extracted for ${audio.title}")
|
||||
logD("No metadata could be extracted for ${raw.name}")
|
||||
}
|
||||
|
||||
return audio.toSong()
|
||||
return Song(raw)
|
||||
}
|
||||
|
||||
private fun completeAudio(metadata: Metadata) {
|
||||
|
@ -215,14 +213,14 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
|||
|
||||
private fun populateId3v2(tags: Map<String, String>) {
|
||||
// (Sort) Title
|
||||
tags["TIT2"]?.let { audio.title = it }
|
||||
tags["TSOT"]?.let { audio.sortTitle = it }
|
||||
tags["TIT2"]?.let { raw.name = it }
|
||||
tags["TSOT"]?.let { raw.sortName = it }
|
||||
|
||||
// Track, as NN/TT
|
||||
tags["TRCK"]?.parsePositionNum()?.let { audio.track = it }
|
||||
tags["TRCK"]?.parsePositionNum()?.let { raw.track = it }
|
||||
|
||||
// Disc, as NN/TT
|
||||
tags["TPOS"]?.parsePositionNum()?.let { audio.disc = it }
|
||||
tags["TPOS"]?.parsePositionNum()?.let { raw.disc = it }
|
||||
|
||||
// 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
|
||||
|
@ -236,26 +234,26 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
|||
(tags["TDOR"]?.parseTimestamp()
|
||||
?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp()
|
||||
?: parseId3v23Date(tags))
|
||||
?.let { audio.date = it }
|
||||
?.let { raw.date = it }
|
||||
|
||||
// (Sort) Album
|
||||
tags["TALB"]?.let { audio.album = it }
|
||||
tags["TSOA"]?.let { audio.sortAlbum = it }
|
||||
tags["TALB"]?.let { raw.albumName = it }
|
||||
tags["TSOA"]?.let { raw.albumSortName = it }
|
||||
|
||||
// (Sort) Artist
|
||||
tags["TPE1"]?.let { audio.artist = it }
|
||||
tags["TSOP"]?.let { audio.sortArtist = it }
|
||||
tags["TPE1"]?.let { raw.artistName = it }
|
||||
tags["TSOP"]?.let { raw.artistSortName = it }
|
||||
|
||||
// (Sort) Album artist
|
||||
tags["TPE2"]?.let { audio.albumArtist = it }
|
||||
tags["TSO2"]?.let { audio.sortAlbumArtist = it }
|
||||
tags["TPE2"]?.let { raw.albumArtistName = it }
|
||||
tags["TSO2"]?.let { raw.albumArtistSortName = it }
|
||||
|
||||
// Genre, with the weird ID3 rules.
|
||||
tags["TCON"]?.let { audio.genre = it.parseId3GenreName() }
|
||||
tags["TCON"]?.let { raw.genreName = it.parseId3GenreName() }
|
||||
|
||||
// Release type (GRP1 is sometimes used for this, so fall back to it)
|
||||
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let {
|
||||
audio.releaseType = it
|
||||
raw.albumReleaseType = it
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -282,14 +280,14 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
|||
|
||||
private fun populateVorbis(tags: Map<String, List<String>>) {
|
||||
// (Sort) Title
|
||||
tags["TITLE"]?.let { audio.title = it[0] }
|
||||
tags["TITLESORT"]?.let { audio.sortTitle = it[0] }
|
||||
tags["TITLE"]?.let { raw.name = it[0] }
|
||||
tags["TITLESORT"]?.let { raw.sortName = it[0] }
|
||||
|
||||
// Track
|
||||
tags["TRACKNUMBER"]?.run { get(0).parsePositionNum() }?.let { audio.track = it }
|
||||
tags["TRACKNUMBER"]?.run { get(0).parsePositionNum() }?.let { raw.track = it }
|
||||
|
||||
// Disc
|
||||
tags["DISCNUMBER"]?.run { get(0).parsePositionNum() }?.let { audio.disc = it }
|
||||
tags["DISCNUMBER"]?.run { get(0).parsePositionNum() }?.let { raw.disc = it }
|
||||
|
||||
// Vorbis dates are less complicated, but there are still several types
|
||||
// Our hierarchy for dates is as such:
|
||||
|
@ -300,25 +298,25 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
|||
(tags["ORIGINALDATE"]?.run { get(0).parseTimestamp() }
|
||||
?: tags["DATE"]?.run { get(0).parseTimestamp() }
|
||||
?: tags["YEAR"]?.run { get(0).parseYear() })
|
||||
?.let { audio.date = it }
|
||||
?.let { raw.date = it }
|
||||
|
||||
// (Sort) Album
|
||||
tags["ALBUM"]?.let { audio.album = it.joinToString() }
|
||||
tags["ALBUMSORT"]?.let { audio.sortAlbum = it.joinToString() }
|
||||
tags["ALBUM"]?.let { raw.albumName = it.joinToString() }
|
||||
tags["ALBUMSORT"]?.let { raw.albumSortName = it.joinToString() }
|
||||
|
||||
// (Sort) Artist
|
||||
tags["ARTIST"]?.let { audio.artist = it.joinToString() }
|
||||
tags["ARTISTSORT"]?.let { audio.sortArtist = it.joinToString() }
|
||||
tags["ARTIST"]?.let { raw.artistName = it.joinToString() }
|
||||
tags["ARTISTSORT"]?.let { raw.artistSortName = it.joinToString() }
|
||||
|
||||
// (Sort) Album artist
|
||||
tags["ALBUMARTIST"]?.let { audio.albumArtist = it.joinToString() }
|
||||
tags["ALBUMARTISTSORT"]?.let { audio.sortAlbumArtist = it.joinToString() }
|
||||
tags["ALBUMARTIST"]?.let { raw.albumArtistName = it.joinToString() }
|
||||
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortName = it.joinToString() }
|
||||
|
||||
// Genre, no ID3 rules here
|
||||
tags["GENRE"]?.let { audio.genre = it.joinToString() }
|
||||
tags["GENRE"]?.let { raw.genreName = it.joinToString() }
|
||||
|
||||
// Release type
|
||||
tags["RELEASETYPE"]?.parseReleaseType()?.let { audio.releaseType = it }
|
||||
tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -262,19 +262,7 @@ class Indexer {
|
|||
}
|
||||
|
||||
// Deduplicate songs to prevent (most) deformed music clones
|
||||
songs =
|
||||
songs
|
||||
.distinctBy {
|
||||
it.rawName to
|
||||
it._albumName to
|
||||
it._artistName to
|
||||
it._albumArtistName to
|
||||
it._genreName to
|
||||
it.track to
|
||||
it.disc to
|
||||
it.durationMs
|
||||
}
|
||||
.toMutableList()
|
||||
songs = songs.distinctBy { it._distinct }.toMutableList()
|
||||
|
||||
// Ensure that sorting order is consistent so that grouping is also consistent.
|
||||
Sort(Sort.Mode.ByName, true).songsInPlace(songs)
|
||||
|
@ -299,7 +287,7 @@ class Indexer {
|
|||
*/
|
||||
private fun buildAlbums(songs: List<Song>): List<Album> {
|
||||
val albums = mutableListOf<Album>()
|
||||
val songsByAlbum = songs.groupBy { it._albumGroupingId }
|
||||
val songsByAlbum = songs.groupBy { it._rawAlbum.groupingId }
|
||||
|
||||
for (entry in songsByAlbum) {
|
||||
val albumSongs = entry.value
|
||||
|
@ -308,18 +296,10 @@ class Indexer {
|
|||
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
|
||||
// weird years like "0" wont show up if there are alternatives.
|
||||
val templateSong =
|
||||
albumSongs.maxWith(compareBy(Sort.Mode.NullableComparator.DATE) { it._date })
|
||||
albumSongs.maxWith(
|
||||
compareBy(Sort.Mode.NullableComparator.DATE) { it._rawAlbum.date })
|
||||
|
||||
albums.add(
|
||||
Album(
|
||||
rawName = templateSong._albumName,
|
||||
rawSortName = templateSong._albumSortName,
|
||||
date = templateSong._date,
|
||||
releaseType = templateSong._albumReleaseType ?: ReleaseType.Album(null),
|
||||
coverUri = templateSong._albumCoverUri,
|
||||
songs = entry.value,
|
||||
_artistGroupingName = templateSong._artistGroupingName,
|
||||
_artistGroupingSortName = templateSong._artistGroupingSortName))
|
||||
albums.add(Album(templateSong._rawAlbum, albumSongs))
|
||||
}
|
||||
|
||||
logD("Successfully built ${albums.size} albums")
|
||||
|
@ -333,16 +313,12 @@ class Indexer {
|
|||
*/
|
||||
private fun buildArtists(albums: List<Album>): List<Artist> {
|
||||
val artists = mutableListOf<Artist>()
|
||||
val albumsByArtist = albums.groupBy { it._artistGroupingId }
|
||||
val albumsByArtist = albums.groupBy { it._rawArtist.groupingId }
|
||||
|
||||
for (entry in albumsByArtist) {
|
||||
// The first album will suffice for template metadata.
|
||||
val templateAlbum = entry.value[0]
|
||||
artists.add(
|
||||
Artist(
|
||||
rawName = templateAlbum._artistGroupingName,
|
||||
rawSortName = templateAlbum._artistGroupingSortName,
|
||||
albums = entry.value))
|
||||
artists.add(Artist(templateAlbum._rawArtist, albums = entry.value))
|
||||
}
|
||||
|
||||
logD("Successfully built ${artists.size} artists")
|
||||
|
@ -356,12 +332,12 @@ class Indexer {
|
|||
*/
|
||||
private fun buildGenres(songs: List<Song>): List<Genre> {
|
||||
val genres = mutableListOf<Genre>()
|
||||
val songsByGenre = songs.groupBy { it._genreGroupingId }
|
||||
val songsByGenre = songs.groupBy { it._rawGenre.groupingId }
|
||||
|
||||
for (entry in songsByGenre) {
|
||||
// The first song fill suffice for template metadata.
|
||||
val templateSong = entry.value[0]
|
||||
genres.add(Genre(rawName = templateSong._genreName, songs = entry.value))
|
||||
genres.add(Genre(templateSong._rawGenre, songs = entry.value))
|
||||
}
|
||||
|
||||
logD("Successfully built ${genres.size} genres")
|
||||
|
|
|
@ -163,9 +163,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
cursor: Cursor,
|
||||
emitIndexing: (Indexer.Indexing) -> Unit
|
||||
): List<Song> {
|
||||
val audios = mutableListOf<Audio>()
|
||||
val rawSongs = mutableListOf<Song.Raw>()
|
||||
while (cursor.moveToNext()) {
|
||||
audios.add(buildAudio(context, cursor))
|
||||
rawSongs.add(buildRawSong(context, cursor))
|
||||
if (cursor.position % 50 == 0) {
|
||||
// Only check for a cancellation every 50 songs or so (~20ms).
|
||||
// While this seems redundant, each call to emitIndexing checks for a
|
||||
|
@ -174,9 +174,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
}
|
||||
}
|
||||
|
||||
// The audio is not actually complete at this point, as we cannot obtain a genre
|
||||
// The raw song is not actually complete at this point, as we cannot obtain a genre
|
||||
// through a song query. Instead, we have to do the hack where we iterate through
|
||||
// every genre and assign it's name to audios that match it's child ID.
|
||||
// every genre and assign it's name to raw songs that match it's child IDs.
|
||||
context.contentResolverSafe.useQuery(
|
||||
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
|
||||
|
@ -199,7 +199,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
|
||||
while (cursor.moveToNext()) {
|
||||
val songId = cursor.getLong(songIdIndex)
|
||||
audios.find { it.id == songId }?.let { song -> song.genre = name }
|
||||
rawSongs
|
||||
.find { it.mediaStoreId == songId }
|
||||
?.let { song -> song.genreName = name }
|
||||
|
||||
if (cursor.position % 50 == 0) {
|
||||
// Only check for a cancellation every 50 songs or so (~20ms).
|
||||
|
@ -214,7 +216,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
emitIndexing(Indexer.Indexing.Indeterminate)
|
||||
}
|
||||
|
||||
return audios.map { it.toSong() }
|
||||
return rawSongs.map { Song(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -242,11 +244,11 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean
|
||||
|
||||
/**
|
||||
* Build an [Audio] based on the current cursor values. Each implementation should try to obtain
|
||||
* an upstream [Audio] first, and then populate it with version-specific fields outlined in
|
||||
* [projection].
|
||||
* Build an [Song.Raw] based on the current cursor values. Each implementation should try to
|
||||
* obtain an upstream [Song.Raw] first, and then populate it with version-specific fields
|
||||
* outlined in [projection].
|
||||
*/
|
||||
open fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
open fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
|
||||
// Initialize our cursor indices if we haven't already.
|
||||
if (idIndex == -1) {
|
||||
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
||||
|
@ -264,105 +266,42 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
|
||||
}
|
||||
|
||||
val audio = Audio()
|
||||
val raw = Song.Raw()
|
||||
|
||||
audio.id = cursor.getLong(idIndex)
|
||||
audio.title = cursor.getString(titleIndex)
|
||||
raw.mediaStoreId = cursor.getLong(idIndex)
|
||||
raw.name = cursor.getString(titleIndex)
|
||||
|
||||
audio.extensionMimeType = cursor.getString(mimeTypeIndex)
|
||||
audio.size = cursor.getLong(sizeIndex)
|
||||
audio.dateAdded = cursor.getLong(dateAddedIndex)
|
||||
raw.extensionMimeType = cursor.getString(mimeTypeIndex)
|
||||
raw.size = cursor.getLong(sizeIndex)
|
||||
raw.dateAdded = cursor.getLong(dateAddedIndex)
|
||||
|
||||
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
|
||||
// from the android system.
|
||||
audio.displayName = cursor.getStringOrNull(displayNameIndex)
|
||||
raw.displayName = cursor.getStringOrNull(displayNameIndex)
|
||||
|
||||
audio.duration = cursor.getLong(durationIndex)
|
||||
audio.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
|
||||
raw.durationMs = cursor.getLong(durationIndex)
|
||||
raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
|
||||
|
||||
// A non-existent album name should theoretically be the name of the folder it contained
|
||||
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
|
||||
// file is not actually in the root internal storage directory. We can't do anything to
|
||||
// fix this, really.
|
||||
audio.album = cursor.getString(albumIndex)
|
||||
audio.albumId = cursor.getLong(albumIdIndex)
|
||||
raw.albumName = cursor.getString(albumIndex)
|
||||
raw.albumMediaStoreId = cursor.getLong(albumIdIndex)
|
||||
|
||||
// Android does not make a non-existent artist tag null, it instead fills it in
|
||||
// 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 =
|
||||
raw.artistName =
|
||||
cursor.getString(artistIndex).run {
|
||||
if (this != MediaStore.UNKNOWN_STRING) this else null
|
||||
}
|
||||
|
||||
// The album artist field is nullable and never has placeholder values.
|
||||
audio.albumArtist = cursor.getStringOrNull(albumArtistIndex)
|
||||
raw.albumArtistName = cursor.getStringOrNull(albumArtistIndex)
|
||||
|
||||
return audio
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a song as it is represented by MediaStore. This is progressively mutated in the
|
||||
* chain of Backend instances until it is complete enough to be transformed into an immutable
|
||||
* song.
|
||||
*/
|
||||
data class Audio(
|
||||
var id: Long? = null,
|
||||
var title: String? = null,
|
||||
var sortTitle: String? = null,
|
||||
var displayName: String? = null,
|
||||
var dir: Directory? = null,
|
||||
var extensionMimeType: String? = null,
|
||||
var formatMimeType: String? = null,
|
||||
var size: Long? = null,
|
||||
var dateAdded: Long? = null,
|
||||
var duration: Long? = null,
|
||||
var track: Int? = null,
|
||||
var disc: Int? = null,
|
||||
var date: Date? = null,
|
||||
var albumId: Long? = null,
|
||||
var album: String? = null,
|
||||
var sortAlbum: String? = null,
|
||||
var releaseType: ReleaseType? = null,
|
||||
var artist: String? = null,
|
||||
var sortArtist: String? = null,
|
||||
var albumArtist: String? = null,
|
||||
var sortAlbumArtist: String? = null,
|
||||
var genre: String? = null
|
||||
) {
|
||||
fun toSong() =
|
||||
Song(
|
||||
// Assert that the fields that should always 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" },
|
||||
rawSortName = sortTitle,
|
||||
path =
|
||||
Path(
|
||||
name = requireNotNull(displayName) { "Malformed audio: No display name" },
|
||||
parent = requireNotNull(dir) { "Malformed audio: No parent directory" }),
|
||||
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
|
||||
mimeType =
|
||||
MimeType(
|
||||
fromExtension =
|
||||
requireNotNull(extensionMimeType) { "Malformed audio: No mime type" },
|
||||
fromFormat = formatMimeType),
|
||||
size = requireNotNull(size) { "Malformed audio: No size" },
|
||||
dateAdded = requireNotNull(dateAdded) { "Malformed audio: No date added" },
|
||||
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
|
||||
track = track,
|
||||
disc = disc,
|
||||
_date = date,
|
||||
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
|
||||
_albumSortName = sortAlbum,
|
||||
_albumReleaseType = releaseType,
|
||||
_albumCoverUri =
|
||||
requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri,
|
||||
_artistName = artist,
|
||||
_artistSortName = sortArtist,
|
||||
_albumArtistName = albumArtist,
|
||||
_albumArtistSortName = sortAlbumArtist,
|
||||
_genreName = genre)
|
||||
return raw
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -415,8 +354,8 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
|
|||
return true
|
||||
}
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
|
||||
val raw = super.buildRawSong(context, cursor)
|
||||
|
||||
// Initialize our indices if we have not already.
|
||||
if (trackIndex == -1) {
|
||||
|
@ -430,8 +369,8 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
|
|||
// that this only applies to below API 29, as beyond API 29, this field not being
|
||||
// present would completely break the scoped storage system. Fill it in with DATA
|
||||
// if it's not available.
|
||||
if (audio.displayName == null) {
|
||||
audio.displayName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
|
||||
if (raw.displayName == null) {
|
||||
raw.displayName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
|
||||
}
|
||||
|
||||
// Find the volume that transforms the DATA field into a relative path. This is
|
||||
|
@ -441,18 +380,18 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
|
|||
val volumePath = volume.directoryCompat ?: continue
|
||||
val strippedPath = rawPath.removePrefix(volumePath)
|
||||
if (strippedPath != rawPath) {
|
||||
audio.dir = Directory.from(volume, strippedPath)
|
||||
raw.directory = Directory.from(volume, strippedPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val rawTrack = cursor.getIntOrNull(trackIndex)
|
||||
if (rawTrack != null) {
|
||||
rawTrack.unpackTrackNo()?.let { audio.track = it }
|
||||
rawTrack.unpackDiscNo()?.let { audio.disc = it }
|
||||
rawTrack.unpackTrackNo()?.let { raw.track = it }
|
||||
rawTrack.unpackDiscNo()?.let { raw.disc = it }
|
||||
}
|
||||
|
||||
return audio
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -485,8 +424,8 @@ open class BaseApi29MediaStoreBackend : MediaStoreBackend() {
|
|||
return true
|
||||
}
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
|
||||
val raw = super.buildRawSong(context, cursor)
|
||||
|
||||
if (volumeIndex == -1) {
|
||||
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
|
||||
|
@ -501,10 +440,10 @@ open class BaseApi29MediaStoreBackend : MediaStoreBackend() {
|
|||
// This is what we use for the Directory's volume.
|
||||
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
|
||||
if (volume != null) {
|
||||
audio.dir = Directory.from(volume, relativePath)
|
||||
raw.directory = Directory.from(volume, relativePath)
|
||||
}
|
||||
|
||||
return audio
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -520,8 +459,8 @@ open class Api29MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
|||
override val projection: Array<String>
|
||||
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
|
||||
val raw = super.buildRawSong(context, cursor)
|
||||
|
||||
if (trackIndex == -1) {
|
||||
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||
|
@ -531,11 +470,11 @@ open class Api29MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
|||
// Use the old field instead.
|
||||
val rawTrack = cursor.getIntOrNull(trackIndex)
|
||||
if (rawTrack != null) {
|
||||
rawTrack.unpackTrackNo()?.let { audio.track = it }
|
||||
rawTrack.unpackDiscNo()?.let { audio.disc = it }
|
||||
rawTrack.unpackTrackNo()?.let { raw.track = it }
|
||||
rawTrack.unpackDiscNo()?.let { raw.disc = it }
|
||||
}
|
||||
|
||||
return audio
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -556,8 +495,8 @@ class Api30MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
|||
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
|
||||
MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
|
||||
val raw = super.buildRawSong(context, cursor)
|
||||
|
||||
// Populate our indices if we have not already.
|
||||
if (trackIndex == -1) {
|
||||
|
@ -569,9 +508,9 @@ class Api30MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
|||
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
|
||||
// N is the number and T is the total. Parse the number while leaving out the
|
||||
// total, as we have no use for it.
|
||||
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { audio.track = it }
|
||||
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { audio.disc = it }
|
||||
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
|
||||
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
|
||||
|
||||
return audio
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
|
|
@ -407,7 +407,7 @@ class MediaSessionComponent(
|
|||
if (settings.useAltNotifAction) {
|
||||
PlaybackStateCompat.CustomAction.Builder(
|
||||
PlaybackService.ACTION_INVERT_SHUFFLE,
|
||||
context.getString(R.string.desc_change_repeat),
|
||||
context.getString(R.string.desc_shuffle),
|
||||
if (playbackManager.isShuffled) {
|
||||
R.drawable.ic_shuffle_on_24
|
||||
} else {
|
||||
|
|
Loading…
Reference in a new issue