Skip to content

Commit ac199e4

Browse files
committed
map track attributes to their min/max and types
1 parent df0892d commit ac199e4

File tree

2 files changed

+113
-43
lines changed

2 files changed

+113
-43
lines changed

src/main/kotlin/com/adamratzman/spotify/endpoints/public/BrowseAPI.kt

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ import com.adamratzman.spotify.models.serialization.toPagingObject
2525
import com.neovisionaries.i18n.CountryCode
2626
import java.text.SimpleDateFormat
2727
import java.time.Instant
28-
import java.util.Date
29-
import java.util.HashMap
28+
import java.util.*
3029
import java.util.function.Supplier
3130

3231
/**
@@ -229,19 +228,75 @@ class BrowseAPI(api: SpotifyAPI) : SpotifyEndpoint(api) {
229228
*
230229
* @throws BadRequestException if any filter is applied illegally
231230
*/
231+
@Suppress("DEPRECATION")
232+
fun getTrackRecommendations(
233+
seedArtists: List<String>? = null,
234+
seedGenres: List<String>? = null,
235+
seedTracks: List<String>? = null,
236+
limit: Int? = null,
237+
market: CountryCode? = null,
238+
targetAttributes: List<TrackAttribute<*>> = listOf(),
239+
minAttributes: List<TrackAttribute<*>> = listOf(),
240+
maxAttributes: List<TrackAttribute<*>> = listOf()
241+
): SpotifyRestAction<RecommendationResponse> =
242+
getRecommendations(
243+
seedArtists,
244+
seedGenres,
245+
seedTracks,
246+
limit,
247+
market,
248+
targetAttributes.map { it.tuneableTrackAttribute to it.value }.toMap(),
249+
minAttributes.map { it.tuneableTrackAttribute to it.value }.toMap(),
250+
maxAttributes.map { it.tuneableTrackAttribute to it.value }.toMap()
251+
)
252+
253+
254+
/**
255+
* Create a playlist-style listening experience based on seed artists, tracks and genres.
256+
* Recommendations are generated based on the available information for a given seed entity and matched against similar
257+
* artists and tracks. If there is sufficient information about the provided seeds, a list of tracks will be returned
258+
* together with pool size details. For artists and tracks that are very new or obscure there might not be enough data
259+
* to generate a list of tracks.
260+
*
261+
* **5** seeds of any combination of [seedArtists], [seedGenres], and [seedTracks] can be provided. AT LEAST 1 seed must be provided.
262+
*
263+
* **All attributes** are weighted equally.
264+
*
265+
* See [here](https://developer.spotify.com/documentation/web-api/reference/browse/get-recommendations/#tuneable-track-attributes) for a list
266+
* and descriptions of tuneable track attributes and their ranges.
267+
*
268+
* @param seedArtists A possibly null provided list of <b>Artist IDs</b> to be used to generate recommendations
269+
* @param seedGenres A possibly null provided list of <b>Genre IDs</b> to be used to generate recommendations. Invalid genres are ignored
270+
* @param seedTracks A possibly null provided list of <b>Track IDs</b> to be used to generate recommendations
271+
* @param limit The number of objects to return. Default: 20. Minimum: 1. Maximum: 50.
272+
* @param market Provide this parameter if you want the list of returned items to be relevant to a particular country.
273+
* If omitted, the returned items will be relevant to all countries.
274+
* @param targetAttributes For each of the tunable track attributes a target value may be provided.
275+
* Tracks with the attribute values nearest to the target values will be preferred.
276+
* @param minAttributes For each tunable track attribute, a hard floor on the selected track attribute’s value can be provided.
277+
* @param maxAttributes For each tunable track attribute, a hard ceiling on the selected track attribute’s value can be provided.
278+
* For example, setting max instrumentalness equal to 0.35 would filter out most tracks that are likely to be instrumental.
279+
*
280+
* @return [RecommendationResponse] with [RecommendationSeed]s used and [SimpleTrack]s found
281+
*
282+
* @throws BadRequestException if any filter is applied illegally
283+
*
284+
*/
285+
@Deprecated("Ambiguous track attribute setting. Please use BrowseAPI#getTrackRecommendations instead")
232286
fun getRecommendations(
233287
seedArtists: List<String>? = null,
234288
seedGenres: List<String>? = null,
235289
seedTracks: List<String>? = null,
236290
limit: Int? = null,
237291
market: CountryCode? = null,
238-
targetAttributes: HashMap<TuneableTrackAttribute, Number> = hashMapOf(),
239-
minAttributes: HashMap<TuneableTrackAttribute, Number> = hashMapOf(),
240-
maxAttributes: HashMap<TuneableTrackAttribute, Number> = hashMapOf()
292+
targetAttributes: Map<TuneableTrackAttribute<*>, Number> = mapOf(),
293+
minAttributes: Map<TuneableTrackAttribute<*>, Number> = mapOf(),
294+
maxAttributes: Map<TuneableTrackAttribute<*>, Number> = mapOf()
241295
): SpotifyRestAction<RecommendationResponse> {
242296
if (seedArtists?.isEmpty() != false && seedGenres?.isEmpty() != false && seedTracks?.isEmpty() != false) {
243297
throw BadRequestException(ErrorObject(400, "At least one seed (genre, artist, track) must be provided."))
244298
}
299+
245300
return toAction(Supplier {
246301
val builder = EndpointBuilder("/recommendations").with("limit", limit).with("market", market?.name)
247302
.with("seed_artists", seedArtists?.joinToString(",") { ArtistURI(it).id.encode() })
@@ -253,75 +308,77 @@ class BrowseAPI(api: SpotifyAPI) : SpotifyEndpoint(api) {
253308
get(builder.toString()).toObject<RecommendationResponse>(api)
254309
})
255310
}
311+
312+
256313
}
257314

258315
/**
259316
* Describes a track attribute
260317
*
261318
* @param attribute The spotify id for the track attribute
262319
*/
263-
enum class TuneableTrackAttribute(private val attribute: String) {
320+
sealed class TuneableTrackAttribute <T: Number>(val attribute: String, val integerOnly: Boolean, val min: T?, val max: T?) {
264321
/**
265322
* A confidence measure from 0.0 to 1.0 of whether the track is acoustic.
266323
* 1.0 represents high confidence the track is acoustic.
267324
*/
268-
ACOUSTICNESS("acousticness"),
325+
object ACOUSTICNESS : TuneableTrackAttribute<Float>("acousticness", false,0f, 1f)
269326
/**
270327
* Danceability describes how suitable a track is for dancing based on a combination of musical
271328
* elements including tempo, rhythm stability, beat strength, and overall regularity. A value of 0.0 is
272329
* least danceable and 1.0 is most danceable.
273330
*/
274-
DANCEABILITY("danceability"),
331+
object DANCEABILITY : TuneableTrackAttribute<Float>("danceability", false,0f, 1f)
275332
/**
276333
* The duration of the track in milliseconds.
277334
*/
278-
DURATION_IN_MILLISECONDS("duration_ms"),
335+
object DURATION_IN_MILLISECONDS : TuneableTrackAttribute<Int>("duration_ms", true,0, null)
279336
/**
280337
* Energy is a measure from 0.0 to 1.0 and represents a perceptual measure of intensity and activity.
281338
* Typically, energetic tracks feel fast, loud, and noisy. For example, death metal has high energy,
282339
* while a Bach prelude scores low on the scale. Perceptual features contributing to this attribute
283340
* include dynamic range, perceived loudness, timbre, onset rate, and general entropy.
284341
*/
285-
ENERGY("energy"),
342+
object ENERGY : TuneableTrackAttribute<Float>("energy", false, 0f, 1f)
286343
/**
287344
* Predicts whether a track contains no vocals. “Ooh” and “aah” sounds are treated as
288345
* instrumental in this context. Rap or spoken word tracks are clearly “vocal”. The
289346
* closer the instrumentalness value is to 1.0, the greater likelihood the track contains
290347
* no vocal content. Values above 0.5 are intended to represent instrumental tracks, but
291348
* confidence is higher as the value approaches 1.0.
292349
*/
293-
INSTRUMENTALNESS("instrumentalness"),
350+
object INSTRUMENTALNESS : TuneableTrackAttribute<Float>("instrumentalness", false,0f, 1f)
294351
/**
295352
* The key the track is in. Integers map to pitches using standard Pitch Class notation.
296353
* E.g. 0 = C, 1 = C♯/D♭, 2 = D, and so on.
297354
*/
298-
KEY("key"),
355+
object KEY : TuneableTrackAttribute<Int>("key", true,0, null)
299356
/**
300357
* Detects the presence of an audience in the recording. Higher liveness values represent an increased
301358
* probability that the track was performed live. A value above 0.8 provides strong likelihood
302359
* that the track is live.
303360
*/
304-
LIVENESS("liveness"),
361+
object LIVENESS : TuneableTrackAttribute<Float>("liveness", false,0f, 1f)
305362
/**
306363
* The overall loudness of a track in decibels (dB). Loudness values are averaged across the
307364
* entire track and are useful for comparing relative loudness of tracks. Loudness is the
308365
* quality of a sound that is the primary psychological correlate of physical strength (amplitude).
309-
* Values typical range between -60 and 0 db.
366+
* Values typically range between -60 and 0 db.
310367
*/
311-
LOUDNESS("loudness"),
368+
object LOUDNESS : TuneableTrackAttribute<Float>("loudness", false,null, null)
312369
/**
313370
* Mode indicates the modality (major or minor) of a track, the type of scale from which its
314371
* melodic content is derived. Major is represented by 1 and minor is 0.
315372
*/
316-
MODE("mode"),
373+
object MODE : TuneableTrackAttribute<Int>("mode", true,0, 1)
317374
/**
318375
* The popularity of the track. The value will be between 0 and 100, with 100 being the most popular.
319376
* The popularity is calculated by algorithm and is based, in the most part, on the total number of
320377
* plays the track has had and how recent those plays are. Note: When applying track relinking via
321378
* the market parameter, it is expected to find relinked tracks with popularities that do not match
322379
* min_*, max_*and target_* popularities. These relinked tracks are accurate replacements for unplayable tracks with the expected popularity scores. Original, non-relinked tracks are available via the linked_from attribute of the relinked track response.
323380
*/
324-
POPULARITY("popularity"),
381+
object POPULARITY : TuneableTrackAttribute<Int>("popularity", true,0, 100)
325382
/**
326383
* Speechiness detects the presence of spoken words in a track. The more exclusively speech-like the
327384
* recording (e.g. talk show, audio book, poetry), the closer to 1.0 the attribute value. Values above
@@ -330,23 +387,36 @@ enum class TuneableTrackAttribute(private val attribute: String) {
330387
* such cases as rap music. Values below 0.33 most likely represent music and other non-speech-like
331388
* tracks.
332389
*/
333-
SPEECHINESS("speechiness"),
390+
object SPEECHINESS : TuneableTrackAttribute<Float>("speechiness", false,0f, 1f)
334391
/**
335392
* The overall estimated tempo of a track in beats per minute (BPM). In musical terminology, tempo is the
336393
* speed or pace of a given piece and derives directly from the average beat duration.
337394
*/
338-
TEMPO("tempo"),
395+
object TEMPO : TuneableTrackAttribute<Float>("tempo", false,0f, null)
339396
/**
340397
* An estimated overall time signature of a track. The time signature (meter)
341398
* is a notational convention to specify how many beats are in each bar (or measure).
342399
*/
343-
TIME_SIGNATURE("time_signature"),
400+
object TIME_SIGNATURE :TuneableTrackAttribute<Int>("time_signature", true,null, null)
344401
/**
345402
* A measure from 0.0 to 1.0 describing the musical positiveness conveyed by a track. Tracks with high
346403
* valence sound more positive (e.g. happy, cheerful, euphoric), while tracks with low valence
347404
* sound more negative (e.g. sad, depressed, angry).
348405
*/
349-
VALENCE("valence");
406+
object VALENCE : TuneableTrackAttribute<Float>("valence", false,0f, 1f)
350407

351408
override fun toString() = attribute
409+
410+
fun asTrackAttribute(value: T): TrackAttribute<T> {
411+
if (min != null && min.toDouble() > value.toDouble()) throw IllegalArgumentException("Attribute value for $this must be greater than $min!")
412+
if (max != null && max.toDouble() < value.toDouble()) throw IllegalArgumentException("Attribute value for $this must be less than $max!")
413+
414+
return TrackAttribute(this, value)
415+
}
416+
}
417+
418+
data class TrackAttribute<T : Number>(val tuneableTrackAttribute: TuneableTrackAttribute<T>, val value: T) {
419+
companion object {
420+
fun <T : Number> create(tuneableTrackAttribute: TuneableTrackAttribute<T>, value: T) = tuneableTrackAttribute.asTrackAttribute(value)
421+
}
352422
}

src/test/kotlin/com/adamratzman/spotify/public/BrowseAPITest.kt

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -51,52 +51,52 @@ class BrowseAPITest : Spek({
5151

5252
describe("get recommendations") {
5353
it("no parameters") {
54-
assertThrows<BadRequestException> { b.getRecommendations().complete() }
54+
assertThrows<BadRequestException> { b.getTrackRecommendations().complete() }
5555
}
5656
it("seed artists") {
57-
assertThrows<BadRequestException> { b.getRecommendations(seedArtists = listOf("abc")).complete() }
58-
assertTrue(b.getRecommendations(seedArtists = listOf("2C2sVVXanbOpymYBMpsi89")).complete().tracks.isNotEmpty())
59-
assertTrue(b.getRecommendations(seedArtists = listOf("2C2sVVXanbOpymYBMpsi89", "7lMgpN1tEBQKpRoUMKB8iw")).complete().tracks.isNotEmpty())
57+
assertThrows<BadRequestException> { b.getTrackRecommendations(seedArtists = listOf("abc")).complete() }
58+
assertTrue(b.getTrackRecommendations(seedArtists = listOf("2C2sVVXanbOpymYBMpsi89")).complete().tracks.isNotEmpty())
59+
assertTrue(b.getTrackRecommendations(seedArtists = listOf("2C2sVVXanbOpymYBMpsi89", "7lMgpN1tEBQKpRoUMKB8iw")).complete().tracks.isNotEmpty())
6060
}
6161
it("seed tracks") {
62-
assertThrows<BadRequestException> { b.getRecommendations(seedTracks = listOf("abc")).complete() }
63-
assertTrue(b.getRecommendations(seedTracks = listOf("3Uyt0WO3wOopnUBCe9BaXl")).complete().tracks.isNotEmpty())
64-
assertTrue(b.getRecommendations(seedTracks = listOf("6d9iYQG2JvTTEgcndW81lt", "3Uyt0WO3wOopnUBCe9BaXl")).complete().tracks.isNotEmpty())
62+
assertThrows<BadRequestException> { b.getTrackRecommendations(seedTracks = listOf("abc")).complete() }
63+
assertTrue(b.getTrackRecommendations(seedTracks = listOf("3Uyt0WO3wOopnUBCe9BaXl")).complete().tracks.isNotEmpty())
64+
assertTrue(b.getTrackRecommendations(seedTracks = listOf("6d9iYQG2JvTTEgcndW81lt", "3Uyt0WO3wOopnUBCe9BaXl")).complete().tracks.isNotEmpty())
6565
}
6666
it("seed genres") {
67-
assertDoesNotThrow { b.getRecommendations(seedGenres = listOf("abc")).complete() }
68-
assertTrue(b.getRecommendations(seedGenres = listOf("pop")).complete().tracks.isNotEmpty())
69-
assertTrue(b.getRecommendations(seedGenres = listOf("pop", "latinx")).complete().tracks.isNotEmpty())
67+
assertDoesNotThrow { b.getTrackRecommendations(seedGenres = listOf("abc")).complete() }
68+
assertTrue(b.getTrackRecommendations(seedGenres = listOf("pop")).complete().tracks.isNotEmpty())
69+
assertTrue(b.getTrackRecommendations(seedGenres = listOf("pop", "latinx")).complete().tracks.isNotEmpty())
7070
}
7171
it("multiple seed types") {
7272
assertDoesNotThrow {
73-
b.getRecommendations(seedArtists = listOf("2C2sVVXanbOpymYBMpsi89"),
73+
b.getTrackRecommendations(seedArtists = listOf("2C2sVVXanbOpymYBMpsi89"),
7474
seedTracks = listOf("6d9iYQG2JvTTEgcndW81lt", "3Uyt0WO3wOopnUBCe9BaXl"),
7575
seedGenres = listOf("pop")).complete()
7676
}
7777
}
7878
it("target attributes") {
79-
assertThrows<BadRequestException> {
80-
b.getRecommendations(targetAttributes = hashMapOf(TuneableTrackAttribute.ACOUSTICNESS to 3)).complete()
79+
assertThrows<IllegalArgumentException> {
80+
b.getTrackRecommendations(targetAttributes = listOf(TuneableTrackAttribute.ACOUSTICNESS.asTrackAttribute(3f))).complete()
8181
}
82-
assertTrue(b.getRecommendations(
83-
targetAttributes = hashMapOf(TuneableTrackAttribute.ACOUSTICNESS to 1.0, TuneableTrackAttribute.DANCEABILITY to .5),
82+
assertTrue(b.getTrackRecommendations(
83+
targetAttributes = listOf(TuneableTrackAttribute.ACOUSTICNESS.asTrackAttribute(1f), TuneableTrackAttribute.DANCEABILITY.asTrackAttribute(0.5f)),
8484
seedGenres = listOf("pop")).complete().tracks.isNotEmpty())
8585
}
8686
it("min attributes") {
87-
assertThrows<BadRequestException> {
88-
b.getRecommendations(minAttributes = hashMapOf(TuneableTrackAttribute.ACOUSTICNESS to 3)).complete()
87+
assertThrows<IllegalArgumentException> {
88+
b.getTrackRecommendations(minAttributes = listOf(TuneableTrackAttribute.ACOUSTICNESS.asTrackAttribute(3f))).complete()
8989
}
90-
assertTrue(b.getRecommendations(
91-
minAttributes = hashMapOf(TuneableTrackAttribute.ACOUSTICNESS to 0.5, TuneableTrackAttribute.DANCEABILITY to .5),
90+
assertTrue(b.getTrackRecommendations(
91+
minAttributes = listOf(TuneableTrackAttribute.ACOUSTICNESS.asTrackAttribute(0.5f), TuneableTrackAttribute.DANCEABILITY.asTrackAttribute(0.5f)),
9292
seedGenres = listOf("pop")).complete().tracks.isNotEmpty())
9393
}
9494
it("max attributes") {
9595
assertThrows<BadRequestException> {
96-
b.getRecommendations(maxAttributes = hashMapOf(TuneableTrackAttribute.SPEECHINESS to 3)).complete()
96+
b.getTrackRecommendations(maxAttributes = listOf(TuneableTrackAttribute.SPEECHINESS.asTrackAttribute(0.9f))).complete()
9797
}
98-
assertTrue(b.getRecommendations(
99-
maxAttributes = hashMapOf(TuneableTrackAttribute.ACOUSTICNESS to 0.9, TuneableTrackAttribute.DANCEABILITY to 0.9),
98+
assertTrue(b.getTrackRecommendations(
99+
maxAttributes = listOf(TuneableTrackAttribute.ACOUSTICNESS.asTrackAttribute(0.9f), TuneableTrackAttribute.DANCEABILITY.asTrackAttribute(0.9f)),
100100
seedGenres = listOf("pop")).complete().tracks.isNotEmpty())
101101
}
102102
}

0 commit comments

Comments
 (0)