MusicBrainz Kotlin Retrofit libraries for Android
Kotlin MusicBrainz/CoverArtArchive Retrofit libraries for Android
Currently in an beta state, mostly stable API.
A few small examples to start:
// Get the artist represented by the ArtistMbid and include all Misc info
brainzSvc.lookupArtist(mbid) { include(*Artist.Include.values()) }
.onSuccess { artist -> handleArtist(artist, mbid) }
.onFailure { brainzMsg -> displayError(brainzMsg.toString()) }
// Get the artist Nirvana's info and include aliases
val nirvana = ArtistMbid("5b11f4ce-a62d-471e-81fc-a69a8278c7da") // maybe obtained via find
lookupArtist(nirvana) { misc(Artist.Misc.Aliases) }
.onSuccess {}
.onFailure {}
// Find releases for the artist name and release title
val jethroTull = ArtistName("Jethro Tull")
val aqualung = AlbumTitle("Aqualung")
findRelease(Limit(4)) { artist(jethroTull) and release(aqualung) }
// Browse events for the given artist and limit the results to 15
val metallicaMbid = ArtistMbid("65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab") // maybe obtained via find
val limit = Limit(15)
browseEvents(EventBrowse.BrowseOn.Artist(metallicaMbid), limit)
// Browse Releases by an artist and limit the results to official, album releases (no bootlegs or
// promos and no singles, compilations, etc)
val theBeatles = ArtistMbid("b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d") // maybe obtained via find
browseReleases(ReleaseBrowse.BrowseOn.Artist(theBeatles)) {
types(ReleaseGroup.Type.Album)
status(Release.Status.Official)
}.onSuccess { browseReleaseList ->
// handle browse
}.onFailure {
// handle error path
}
// Find all ReleaseGroups by The Beatles whose first release date was between 1967 and 1969
// inclusively, where the release was an album, but not a compilation or interview, and was an
// official release (not bootleg or promotion)
findReleaseGroup {
artist(ArtistName("The Beatles")) and
firstReleaseDate { Year("1967") inclusive Year("1969") } and
primaryType(ReleaseGroup.Type.Album) and
!secondaryType { ReleaseGroup.Type.Compilation or ReleaseGroup.Type.Interview } and
status(Release.Status.Official)
}.onSuccess { releaseGroupList ->
// handle group list
}.onFailure {
// handle error path
}
The design philosophy is to provide a type safe interface to the MusicBrainz server, dispatching
work on a background thread using coroutine dispatchers, providing many of the requirements for
well-behaved clients (rate limiting, user agent, etc), and converting responses to easily handled
results. Given the complexity of a typical call path regarding the litany of possible errors with
calling remote servers, parsing Json, etc., special care is given to returned values. A Result
monad style was chosen to make sunny day and error path code straightforward and to avoid throwing
exceptions across coroutine boundaries. This is not a functional library, but the style of
Railway Oriented Programming fits very nicely with handling the various result
possibilities.
For lookup and browse functions, a call specific lambda receiver is provided to guide the client
with regard to what options are available without needing to know the underlying details. Find
functions provide call specific lambda receivers which are the base of a relatively simple, but
extensive, DSL for building a lucene query. This style provides type safety and attempts to
constrain choices to a valid set of options. Using Android Studio’s basic or smart completion inside
one of the lambda receivers will display the possibilities for building the lookup, browse, or find
in scope. Using the bare MusicBrainz retrofit client would require extensive string building which
can be error-prone and lacks type support. There are quite a few value (inline) classes to provide
type support without generating extra garbage.
The current version covers the majority of the MusicBrainz API. There is an escape hatch of
sorts in that the client can indirectly call the Retrofit interfaces via the MusicBrainzService
interface and have correct dispatching and error handling behavior. This is the same method used
internally. There are currently no write capabilities (can’t set ratings or create collections),
This is a Kotlin library and not much thought was given to possible Java clients. Input and pull
requests are welcome.
This repository consists of 3 parts:
Check here and here for the latest published
releases. Pull requests welcome.
For the latest SNAPSHOT check here and here
Provides MusicBrainz and CoverArt interfaces which Retrofit.Builder can use to generate a REST
client for the MusicBrainz and CoverArtArchive servers. This module contains the bulk of the code
to build the requests and decode the responses into objects.
The data classes created as response to MusicBrainz requests, plus added annotations/JsonAdapters,
are provided to support the Null Object Pattern. Null is avoided almost entirely (one specific case
remains). Null Strings become empty Strings, null Lists become empty lists, and a null reference
is replaced by a specific instance of the class - known as a Null Object.
Missing objects default to the their Null Object counterparts. Checking for null is not required,
but it is possible to check for the Null Object via instance comparison. The Area class provides a
short example:
@JsonClass(generateAdapter = true)
data class Area(
var id: String = "",
var name: String = "",
@field:Json(name = "sort-name") var sortName: String = "",
var disambiguation: String = "",
@field:Json(name = "iso-3166-1-codes") var iso31661Codes: List<String> = emptyList()
) {
companion object {
val NullArea = Area()
val fallbackMapping: Pair<String, Any> = Area::class.java.name to NullArea
}
}
inline val Area.isNullObject
get() = this === NullArea
@JvmInline
value class AreaMbid(override val value: String) : Mbid
inline val Area.mbid
get() = AreaMbid(id)
A companion object is defined which contains the Null Object and a mapping between the class name
and the fallback NullArea object. An extension function defines a Boolean isNullObject val. Also
note the AreaMbid value class. Since a MusicBrainz identifier (MBID) is just a string, these inline
classes are meant to differentiate types of MBID to facilitate compile time type checking.
The MusicBrainz and CoverArt interfaces are defined with suspend functions, so are only callable
from a coroutine. It is expected that the service module will be used to handle constructing and
calling the generated Retrofit classes.
interface CoverArt {
/**
* An example for looking up release artwork by mbid would be:
* https://coverartarchive.org/release/91975b77-c9f2-46d1-a03b-f1fffbda1d1c
*
* @param entity either "release" or "release-group"
* @param mbid the release or release-group mbid. In the example this would be:
* 91975b77-c9f2-46d1-a03b-f1fffbda1d1c
*
* @return the CoverArtRelease associated with the mbid, wrapped in a Response
*/
@GET("{entity}/{mbid}")
suspend fun getArtwork(
@Path("entity") entity: String,
@Path("mbid") mbid: String
): Response<CoverArtRelease>
}
Note that getArtwork()
is suspending and may only be called from a coroutine. The higher level
abstractions in ealvabrainz-service also define suspend functions along with providing flows
of images.
Provides CoverArtService and MusicBrainzService, which wrap the CoverArt and MusicBrainz Retrofit
clients providing a higher-level function.
The CoverArtService provides functions to retrieve artwork based on an MusicBrainz ID (MBID). It
also has extension functions to convert flows of MBIDs to cover art images. The CoverArtService
implementation builds and contains the necessary OkHttp client and Retrofit implementation of the
CoverArt class.
interface CoverArtService {
suspend fun getReleaseArt(mbid: ReleaseMbid): CoverArtResult
suspend fun getReleaseGroupArt(mbid: ReleaseGroupMbid): CoverArtResult
companion object {
/**
* Instantiate a CoverArtService implementation which handles MusicBrainz server requirements
* such as a required User-Agent format, throttling requests, and factories/adapters to support
* converting Json to objects.
*/
operator fun invoke(
ctx: Context,
appName: String,
appVersion: String,
contactEmail: String,
dispatcher: CoroutineDispatcher = Dispatchers.IO
): CoverArtService
}
}
fun Flow<ReleaseMbid>.transform(service: CoverArtService): Flow<CoverArtImageInfo>
fun Flow<ReleaseGropMbid>.transform(service: CoverArtService): Flow<CoverArtImageInfo>
This service is similar to CoverArtService in that it provides a higher-level abstraction and builds
the appropriate underlying Retrofit/OkHttp classes. MusicBrainzService has functions that take type
specific parameters and format these into parameters for the underlying calls to the MusicBrainz
Retrofit client. There is also a generic brainz()
function accepting a lambda which allows
direct calls to the MusicBrainz Retrofit client while providing correct coroutine dispatch and
simplifying error handling.
The MusicBrainz server API is extensively supported. Below are 3 examples of a lookup, a browse,
a find (query), and brainz function which underlies all calls to the server.
typealias BrainzCall<T> = suspend MusicBrainz.() -> Response<T>
typealias BrainzResult<T> = Result<T, BrainzMessage>
interface MusicBrainzService {
/**
* Find the Artist with the mbid ID. Provide an optional lambda with an ArtistLookup
* receiver to specify if any other information should be included.
*/
suspend fun lookupArtist(
mbid: ArtistMbid,
lookup: ArtistLookup.() -> Unit = {}
): BrainzResult<Artist>
/**
* Browse the recordings of the entity specified by [browseOn] (eg. Artist, Collection, Release,
* or Work). Use [limit] and [offset] to page through the results. Provide an optional lambda with
* a RecordingBrowse receiver to specify if other information should be included, such as
* Artist Credits or some other relationships. BrowseRecordingList contains the total
* number of Recordings, the offset returned, and a list of Recording objects.
*/
suspend fun browseRecordings(
browseOn: RecordingBrowse.BrowseOn,
limit: Limit? = null,
offset: Offset? = null,
browse: RecordingBrowse.() -> Unit = {}
): BrainzResult<BrowseRecordingList>
suspend fun findRelease(
limit: Limit? = null,
offset: Offset? = null,
search: ReleaseSearch.() -> Unit
): BrainzResult<ReleaseList>
// Calls the [block]
suspend fun <T : Any> brainz(
block: suspend MusicBrainz.() -> Response<T>
): Result<T, BrainzMessage>
}
The MusicBrainzService is constructed with a CoverArtService instance. This allows
MusicBrainzService to provide functionality such as:
suspend fun getReleaseGroupArtwork(mbid: ReleaseGroupMbid): Uri
A small find release group example showing the query DSL:
val result = findReleaseGroup {
artist(LED_ZEPPELIN) and releaseGroup(HOUSES_OF_THE_HOLY)
}
The ReleaseGroupSearch supports all 17 possible query fields and the term DSL support: required,
prohibited, regular expressions, ranges, fuzzy search, proximity, and boosting. See the MusicBrainz
docs for details.
val revolver = Field("album", Term("Revolver"))
val rubberSoul = Field("album", Term("Rubber Soul"))
val beatles = Field("artist", +Term("The Beatles")) // + operator indicated required term
val exp = beatles and (revolver or rubberSoul)
val exp2 = beatles and revolver or rubberSoul
Most MusicBrainzService functions return a Result
modelling success (Ok) or failure (Err) operations. When Result is of type Ok, the
value of type T is the result of the call to MusicBrainz. If an Err is returned, the error is a
subtype of BrainzMessage which indicates the type of error. Result is from the
kotlin-result library and provides a nice implementation for
Railway Oriented Programming.
Several items must be defined in the local.properties file in the root folder of this project to
successfully build and run the app and integration tests. If the file doesn’t exist, create it and
add the following (substituting your valid information):
BRAINZ_APP_NAME="YourAppName"
BRAINZ_APP_VERSION="0.0.1"
BRAINZ_CONTACT_EMAIL="your@email.com"
The fields will be combined into a user agent passed to the servers.
One or more integration tests require authentication with the MusicBrainz server. The required
username and password must be defined in the local.properties file in the root folder of this
project. This file is not committed to version control as it’s contents are private (obviously).
It would typically look something like:
BRAINZ_USERNAME="my_username"
BRAINZ_PASSWORD="my_password"
where BRAINZ_USERNAME and BRAINZ_PASSWORD are set to your MusicBrainz.org credentials. If you don’t
have an account, go to https://musicbrainz.org/register and create one. Not mandatory, however not
setting these values correctly will result in some tests failing due to authentication errors.
Applications which call functions requiring authentication must implement the CredentialsProvider
interface and use it when constructing a MusicBrainzService implementation.
The application demonstrates searching, browsing, and display of various MusicBrainz entities. Only
a few APIs are called - look at the integration tests for more examples.
Given Kotlin coroutines, flows, and lifecycle scope, it is easy to define flows that properly
set and remove listeners based on component lifecycle, to conflate events, and to possibly emit
richer objects than provided by underlying Views. Consumers only need to collect from a flow and
the underlying listener is properly registered/unregistered based on lifecycle.
The app uses no layout XML and instead uses a Kotlin DSL from the Splitties library.
The UI is defined in a way so as to segregate UI functionality and, as a result, classes such as
Activities, Fragments, ViewHolders, etc. are very small. Using this DSL keeps the UI definition
and implementation together in a single file/class, is inherently type safe/null safe, eliminates
the need for findViewById or view binding, eliminates reflection used during inflation, and greatly
reduces development friction (1 language/1 class vs 2 languages/multiple files).
It’s expected the app will be ported to Compose some time in the future.
This library contains classes others may find useful in a different context. While not necessarily
canonical, these may be used as examples or a starting point: