music: add basic release type support [#158]
Add release type support to the media indexer. This allows EPs, Singles, and Albums to be distinguished from eachother. Auxio's implementation will use the MusicBrainz tag for ID3v2 (Falling back to GRP1, as that is used sometimes), and will use RELEASETYPE for vorbis comments.
This commit is contained in:
parent
f838fc8b0e
commit
5df8edf912
7 changed files with 60 additions and 17 deletions
|
@ -19,7 +19,6 @@ package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.method.MovementMethod
|
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
|
@ -27,8 +26,8 @@ import org.oxycblt.auxio.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [TextInputEditText] that deliberately restricts all input except for selection. Yes, this is a
|
* A [TextInputEditText] that deliberately restricts all input except for selection. Yes, this is a
|
||||||
* blatant abuse of Material Design Guidelines, but I also don't want to figure out how to main
|
* blatant abuse of Material Design Guidelines, but I also don't want to figure out how to plain
|
||||||
* plain text selectable.
|
* text selectable.
|
||||||
*
|
*
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*/
|
*/
|
||||||
|
@ -47,9 +46,9 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getFreezesText(): Boolean = false
|
override fun getFreezesText() = false
|
||||||
|
|
||||||
override fun getDefaultEditable(): Boolean = false
|
override fun getDefaultEditable() = false
|
||||||
|
|
||||||
override fun getDefaultMovementMethod(): MovementMethod? = null
|
override fun getDefaultMovementMethod() = null
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,7 +74,7 @@ abstract class BaseFetcher : Fetcher {
|
||||||
fetchMediaStoreCovers(context, album)
|
fetchMediaStoreCovers(context, album)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logW("Unable to extract album art due to an error: $e")
|
logW("Unable to extract album cover due to an error: $e")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -203,7 +203,7 @@ abstract class BaseFetcher : Fetcher {
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? {
|
private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? {
|
||||||
val uri = data.albumCoverUri
|
val uri = data.coverUri
|
||||||
|
|
||||||
// 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) { context.contentResolver.openInputStream(uri) }
|
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
|
||||||
|
|
|
@ -96,6 +96,8 @@ data class Song(
|
||||||
/** Internal field. Do not use. */
|
/** Internal field. Do not use. */
|
||||||
val _albumSortName: String?,
|
val _albumSortName: String?,
|
||||||
/** Internal field. Do not use. */
|
/** Internal field. Do not use. */
|
||||||
|
val _albumType: Album.Type,
|
||||||
|
/** Internal field. Do not use. */
|
||||||
val _albumCoverUri: Uri,
|
val _albumCoverUri: Uri,
|
||||||
/** Internal field. Do not use. */
|
/** Internal field. Do not use. */
|
||||||
val _artistName: String?,
|
val _artistName: String?,
|
||||||
|
@ -202,9 +204,15 @@ data class Song(
|
||||||
data class Album(
|
data class Album(
|
||||||
override val rawName: String,
|
override val rawName: String,
|
||||||
override val rawSortName: String?,
|
override val rawSortName: String?,
|
||||||
|
/** The date this album was released. */
|
||||||
val date: Date?,
|
val date: Date?,
|
||||||
/** The URI for the cover art corresponding to this album. */
|
/**
|
||||||
val albumCoverUri: Uri,
|
* The type of release this album represents. Null if release types were not applicable to this
|
||||||
|
* library.
|
||||||
|
*/
|
||||||
|
val type: Type?,
|
||||||
|
/** The URI for the cover image corresponding to this album. */
|
||||||
|
val coverUri: Uri,
|
||||||
/** The songs of this album. */
|
/** The songs of this album. */
|
||||||
override val songs: List<Song>,
|
override val songs: List<Song>,
|
||||||
/** Internal field. Do not use. */
|
/** Internal field. Do not use. */
|
||||||
|
@ -246,6 +254,12 @@ data class Album(
|
||||||
fun _link(artist: Artist) {
|
fun _link(artist: Artist) {
|
||||||
_artist = artist
|
_artist = artist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class Type {
|
||||||
|
Album,
|
||||||
|
EP,
|
||||||
|
Single
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -45,7 +45,7 @@ fun <R> ContentResolver.useQuery(
|
||||||
): R? = queryCursor(uri, projection, selector, args)?.use(block)
|
): R? = queryCursor(uri, projection, selector, args)?.use(block)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For some reason the album art URI namespace does not have a member in [MediaStore], but it still
|
* For some reason the album cover URI namespace does not have a member in [MediaStore], but it still
|
||||||
* works since at least API 21.
|
* works since at least API 21.
|
||||||
*/
|
*/
|
||||||
private val EXTERNAL_ALBUM_ART_URI = Uri.parse("content://media/external/audio/albumart")
|
private val EXTERNAL_ALBUM_ART_URI = Uri.parse("content://media/external/audio/albumart")
|
||||||
|
@ -100,6 +100,17 @@ fun String.parseSortName() =
|
||||||
else -> this
|
else -> this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.parseReleaseType() =
|
||||||
|
parseReleaseTypeImpl() ?: split("+", limit = 2)[0].trim().parseReleaseTypeImpl()
|
||||||
|
|
||||||
|
private fun String.parseReleaseTypeImpl() =
|
||||||
|
when (this) {
|
||||||
|
"album" -> Album.Type.Album
|
||||||
|
"ep" -> Album.Type.EP
|
||||||
|
"single" -> Album.Type.Single
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
|
* Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
|
||||||
* that Auxio uses.
|
* that Auxio uses.
|
||||||
|
|
|
@ -30,6 +30,7 @@ import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.audioUri
|
import org.oxycblt.auxio.music.audioUri
|
||||||
import org.oxycblt.auxio.music.parseId3GenreName
|
import org.oxycblt.auxio.music.parseId3GenreName
|
||||||
import org.oxycblt.auxio.music.parsePositionNum
|
import org.oxycblt.auxio.music.parsePositionNum
|
||||||
|
import org.oxycblt.auxio.music.parseReleaseType
|
||||||
import org.oxycblt.auxio.music.parseTimestamp
|
import org.oxycblt.auxio.music.parseTimestamp
|
||||||
import org.oxycblt.auxio.music.parseYear
|
import org.oxycblt.auxio.music.parseYear
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -177,7 +178,7 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
||||||
for (i in 0 until metadata.length()) {
|
for (i in 0 until metadata.length()) {
|
||||||
when (val tag = metadata[i]) {
|
when (val tag = metadata[i]) {
|
||||||
is TextInformationFrame -> {
|
is TextInformationFrame -> {
|
||||||
val id = tag.id.sanitize()
|
val id = tag.description?.let { "TXXX:${it.sanitize()}" } ?: tag.id.sanitize()
|
||||||
val value = tag.value.sanitize()
|
val value = tag.value.sanitize()
|
||||||
if (value.isNotEmpty()) {
|
if (value.isNotEmpty()) {
|
||||||
id3v2Tags[id] = value
|
id3v2Tags[id] = value
|
||||||
|
@ -245,6 +246,11 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
||||||
|
|
||||||
// Genre, with the weird ID3 rules.
|
// Genre, with the weird ID3 rules.
|
||||||
tags["TCON"]?.let { audio.genre = it.parseId3GenreName() }
|
tags["TCON"]?.let { audio.genre = it.parseId3GenreName() }
|
||||||
|
|
||||||
|
// Release type
|
||||||
|
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let {
|
||||||
|
audio.albumType = it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseId3v23Date(tags: Map<String, String>): Date? {
|
private fun parseId3v23Date(tags: Map<String, String>): Date? {
|
||||||
|
@ -303,6 +309,9 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
||||||
|
|
||||||
// Genre, no ID3 rules here
|
// Genre, no ID3 rules here
|
||||||
tags["GENRE"]?.let { audio.genre = it }
|
tags["GENRE"]?.let { audio.genre = it }
|
||||||
|
|
||||||
|
// Release type
|
||||||
|
tags["RELEASETYPE"]?.parseReleaseType()?.let { audio.albumType = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -224,7 +224,6 @@ class Indexer {
|
||||||
|
|
||||||
val albums = buildAlbums(songs)
|
val albums = buildAlbums(songs)
|
||||||
val artists = buildArtists(albums)
|
val artists = buildArtists(albums)
|
||||||
|
|
||||||
val genres = buildGenres(songs)
|
val genres = buildGenres(songs)
|
||||||
|
|
||||||
// Sanity check: Ensure that all songs are linked up to albums/artists/genres.
|
// Sanity check: Ensure that all songs are linked up to albums/artists/genres.
|
||||||
|
@ -294,13 +293,20 @@ class Indexer {
|
||||||
* that all songs are unified under a single album.
|
* that all songs are unified under a single album.
|
||||||
*
|
*
|
||||||
* This does come with some costs, it's far slower than using the album ID itself, and it may
|
* This does come with some costs, it's far slower than using the album ID itself, and it may
|
||||||
* result in an unrelated album art being selected depending on the song chosen as the template,
|
* result in an unrelated album cover being selected depending on the song chosen as the
|
||||||
* but it seems to work pretty well.
|
* template, but it seems to work pretty well.
|
||||||
*/
|
*/
|
||||||
private fun buildAlbums(songs: List<Song>): List<Album> {
|
private fun buildAlbums(songs: List<Song>): List<Album> {
|
||||||
val albums = mutableListOf<Album>()
|
val albums = mutableListOf<Album>()
|
||||||
val songsByAlbum = songs.groupBy { it._albumGroupingId }
|
val songsByAlbum = songs.groupBy { it._albumGroupingId }
|
||||||
|
|
||||||
|
// If album types aren't used by the music library (Represented by all songs having
|
||||||
|
// an album type), there is no point in displaying them.
|
||||||
|
val enableAlbumTypes = songs.any { it._albumType != Album.Type.Album }
|
||||||
|
if (!enableAlbumTypes) {
|
||||||
|
logD("No distinct album types detected, ignoring them")
|
||||||
|
}
|
||||||
|
|
||||||
for (entry in songsByAlbum) {
|
for (entry in songsByAlbum) {
|
||||||
val albumSongs = entry.value
|
val albumSongs = entry.value
|
||||||
|
|
||||||
|
@ -315,7 +321,8 @@ class Indexer {
|
||||||
rawName = templateSong._albumName,
|
rawName = templateSong._albumName,
|
||||||
rawSortName = templateSong._albumSortName,
|
rawSortName = templateSong._albumSortName,
|
||||||
date = templateSong._date,
|
date = templateSong._date,
|
||||||
albumCoverUri = templateSong._albumCoverUri,
|
type = if (enableAlbumTypes) templateSong._albumType else null,
|
||||||
|
coverUri = templateSong._albumCoverUri,
|
||||||
songs = entry.value,
|
songs = entry.value,
|
||||||
_artistGroupingName = templateSong._artistGroupingName,
|
_artistGroupingName = templateSong._artistGroupingName,
|
||||||
_artistGroupingSortName = templateSong._artistGroupingSortName))
|
_artistGroupingSortName = templateSong._artistGroupingSortName))
|
||||||
|
|
|
@ -27,6 +27,7 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.core.database.getIntOrNull
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Date
|
import org.oxycblt.auxio.music.Date
|
||||||
import org.oxycblt.auxio.music.Directory
|
import org.oxycblt.auxio.music.Directory
|
||||||
import org.oxycblt.auxio.music.MimeType
|
import org.oxycblt.auxio.music.MimeType
|
||||||
|
@ -336,9 +337,10 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
||||||
var track: Int? = null,
|
var track: Int? = null,
|
||||||
var disc: Int? = null,
|
var disc: Int? = null,
|
||||||
var date: Date? = null,
|
var date: Date? = null,
|
||||||
|
var albumId: Long? = null,
|
||||||
var album: String? = null,
|
var album: String? = null,
|
||||||
var sortAlbum: String? = null,
|
var sortAlbum: String? = null,
|
||||||
var albumId: Long? = null,
|
var albumType: Album.Type? = null,
|
||||||
var artist: String? = null,
|
var artist: String? = null,
|
||||||
var sortArtist: String? = null,
|
var sortArtist: String? = null,
|
||||||
var albumArtist: String? = null,
|
var albumArtist: String? = null,
|
||||||
|
@ -369,6 +371,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
||||||
_date = date,
|
_date = date,
|
||||||
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
|
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
|
||||||
_albumSortName = sortAlbum,
|
_albumSortName = sortAlbum,
|
||||||
|
_albumType = albumType ?: Album.Type.Album,
|
||||||
_albumCoverUri =
|
_albumCoverUri =
|
||||||
requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri,
|
requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri,
|
||||||
_artistName = artist,
|
_artistName = artist,
|
||||||
|
|
Loading…
Reference in a new issue