From 7c26babe27a1d30a3a4b4f140fd558297b8b40f7 Mon Sep 17 00:00:00 2001 From: Steve Soltys Date: Wed, 14 Dec 2022 00:56:38 -0500 Subject: [PATCH 1/3] Add support for fetching music charts --- .github/workflows/build.yml | 25 ++++ .travis.yml | 4 - build.gradle | 19 ++- .../com/stevesoltys/applemusic/AppleMusic.kt | 40 +++++-- .../model/album/AlbumChartResponse.kt | 7 ++ .../applemusic/model/album/AlbumResponse.kt | 7 +- .../applemusic/model/chart/ChartResponse.kt | 8 ++ .../applemusic/model/chart/ChartResultType.kt | 11 ++ .../applemusic/model/chart/ChartResults.kt | 13 +++ .../applemusic/model/chart/ChartType.kt | 9 ++ .../model/track/song/SongChartResponse.kt | 7 ++ .../model/track/song/SongResponse.kt | 10 ++ .../applemusic/net/AppleMusicObjectMapper.kt | 6 + .../applemusic/net/AppleMusicService.kt | 20 +++- .../applemusic/AppleMusicE2ETest.kt | 109 ++++++++++++++++++ 15 files changed, 270 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 .travis.yml create mode 100644 src/main/kotlin/com/stevesoltys/applemusic/model/album/AlbumChartResponse.kt create mode 100644 src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResponse.kt create mode 100644 src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResultType.kt create mode 100644 src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResults.kt create mode 100644 src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartType.kt create mode 100644 src/main/kotlin/com/stevesoltys/applemusic/model/track/song/SongChartResponse.kt create mode 100644 src/main/kotlin/com/stevesoltys/applemusic/model/track/song/SongResponse.kt create mode 100644 src/test/kotlin/com/stevesoltys/applemusic/AppleMusicE2ETest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0c8a33c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Build and test +on: + - pull_request + - push + +jobs: + build: + name: Build and test + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11.0.4 + + - name: Build and test + run: | + ./gradlew :build --stacktrace + env: + TEAM_ID: ${{ secrets.TEAM_ID }} + KEY_ID: ${{ secrets.KEY_ID }} + PRIVATE_KEY_BASE64: ${{ secrets.PRIVATE_KEY_BASE64 }} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4a39d02..0000000 --- a/.travis.yml +++ /dev/null @@ -1,4 +0,0 @@ -language: java - -jdk: -- openjdk11 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8206926..b9ac75e 100644 --- a/build.gradle +++ b/build.gradle @@ -8,17 +8,22 @@ version "0.1.0" repositories { mavenCentral() - jcenter() } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1' + implementation "com.squareup.retrofit2:retrofit:2.9.0" - implementation "com.squareup.retrofit2:converter-jackson:2.1.0" - implementation 'io.jsonwebtoken:jjwt-api:0.11.2' - implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' - implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' + implementation "com.squareup.retrofit2:converter-jackson:2.9.0" + + implementation "io.jsonwebtoken:jjwt-api:0.11.5" + implementation "io.jsonwebtoken:jjwt-impl:0.11.5" + implementation "io.jsonwebtoken:jjwt-jackson:0.11.5" + + testImplementation 'io.kotest:kotest-runner-junit5:5.5.4' + testImplementation "io.kotest:kotest-assertions-core:5.5.4" } compileKotlin { @@ -29,4 +34,6 @@ compileTestKotlin { kotlinOptions.jvmTarget = "1.8" } -mainClassName = 'com.stevesoltys.applemusic.Main' \ No newline at end of file +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/AppleMusic.kt b/src/main/kotlin/com/stevesoltys/applemusic/AppleMusic.kt index 3e7432b..a4019ca 100644 --- a/src/main/kotlin/com/stevesoltys/applemusic/AppleMusic.kt +++ b/src/main/kotlin/com/stevesoltys/applemusic/AppleMusic.kt @@ -2,6 +2,9 @@ package com.stevesoltys.applemusic import com.stevesoltys.applemusic.model.album.AlbumResponse import com.stevesoltys.applemusic.model.artist.ArtistResponse +import com.stevesoltys.applemusic.model.chart.ChartResponse +import com.stevesoltys.applemusic.model.chart.ChartResultType +import com.stevesoltys.applemusic.model.chart.ChartType import com.stevesoltys.applemusic.model.search.SearchResponse import com.stevesoltys.applemusic.model.search.SearchResultType import com.stevesoltys.applemusic.net.AppleMusicHttpException @@ -66,6 +69,31 @@ class AppleMusic( ) } + /** + * Get catalog charts. + */ + fun getCatalogCharts( + types: Set, + localization: String? = null, + chart: String? = null, + offset: String? = null, + limit: Int? = null, + genre: String? = null, + with: Set? = null + ): ChartResponse { + return call( + appleMusicService.getCatalogCharts( + types = types.map { it.identifier }.toTypedArray(), + localization = localization, + chart = chart, + offset = offset, + limit = limit, + genre = genre, + with = with?.map { it.identifier }?.toTypedArray() + ) + ) + } + /** * Get an artist. */ @@ -104,23 +132,17 @@ class AppleMusic( val limit = 100 var offset = 0 - var currentResponse = - call(appleMusicService.getAlbumsByArtistId(id, offset.toString(), limit)) - + var currentResponse = call(appleMusicService.getAlbumsByArtistId(id, 0.toString(), limit)) val result = currentResponse.data.toCollection(ArrayList()) while (currentResponse.next != null) { offset += currentResponse.data.size - currentResponse = - call(appleMusicService.getAlbumsByArtistId(id, offset.toString(), limit)) - + currentResponse = call(appleMusicService.getAlbumsByArtistId(id, offset.toString(), limit)) result.addAll(currentResponse.data) } - val response = AlbumResponse() - response.data = result.toTypedArray() - return response + return AlbumResponse(result.toTypedArray()) } private fun call(call: Call): T { diff --git a/src/main/kotlin/com/stevesoltys/applemusic/model/album/AlbumChartResponse.kt b/src/main/kotlin/com/stevesoltys/applemusic/model/album/AlbumChartResponse.kt new file mode 100644 index 0000000..ec0c645 --- /dev/null +++ b/src/main/kotlin/com/stevesoltys/applemusic/model/album/AlbumChartResponse.kt @@ -0,0 +1,7 @@ +package com.stevesoltys.applemusic.model.album + +class AlbumChartResponse( + val chart: String, + val name: String, + data: Array = emptyArray() +) : AlbumResponse(data) \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/model/album/AlbumResponse.kt b/src/main/kotlin/com/stevesoltys/applemusic/model/album/AlbumResponse.kt index 0417cff..88f5a91 100644 --- a/src/main/kotlin/com/stevesoltys/applemusic/model/album/AlbumResponse.kt +++ b/src/main/kotlin/com/stevesoltys/applemusic/model/album/AlbumResponse.kt @@ -5,7 +5,6 @@ import com.stevesoltys.applemusic.model.ResponseRoot /** * @author Steve Soltys */ -class AlbumResponse : ResponseRoot() { - - lateinit var data: Array -} \ No newline at end of file +open class AlbumResponse( + val data: Array = emptyArray() +) : ResponseRoot() \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResponse.kt b/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResponse.kt new file mode 100644 index 0000000..67fdfbd --- /dev/null +++ b/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResponse.kt @@ -0,0 +1,8 @@ +package com.stevesoltys.applemusic.model.chart + +/** + * @author Steve Soltys + */ +class ChartResponse( + val results: ChartResults +) \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResultType.kt b/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResultType.kt new file mode 100644 index 0000000..2afd2e6 --- /dev/null +++ b/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResultType.kt @@ -0,0 +1,11 @@ +package com.stevesoltys.applemusic.model.chart + +/** + * @author Steve Soltys + */ +enum class ChartResultType(val identifier: String) { + ALBUMS("albums"), + MUSIC_VIDEOS("music-videos"), + PLAYLISTS("playlists"), + SONGS("songs") +} \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResults.kt b/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResults.kt new file mode 100644 index 0000000..eb3049d --- /dev/null +++ b/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartResults.kt @@ -0,0 +1,13 @@ +package com.stevesoltys.applemusic.model.chart + +import com.stevesoltys.applemusic.model.album.AlbumChartResponse +import com.stevesoltys.applemusic.model.artist.ArtistResponse +import com.stevesoltys.applemusic.model.track.song.SongChartResponse + +/** + * @author Steve Soltys + */ +class ChartResults( + val albums: Array = emptyArray(), + val songs: Array = emptyArray() +) \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartType.kt b/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartType.kt new file mode 100644 index 0000000..56775d0 --- /dev/null +++ b/src/main/kotlin/com/stevesoltys/applemusic/model/chart/ChartType.kt @@ -0,0 +1,9 @@ +package com.stevesoltys.applemusic.model.chart + +/** + * @author Steve Soltys + */ +enum class ChartType(val identifier: String) { + CITY_CHARTS("cityCharts"), + DAILY_GLOBAL_TOP_CHARTS("dailyGlobalTopCharts") +} \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/model/track/song/SongChartResponse.kt b/src/main/kotlin/com/stevesoltys/applemusic/model/track/song/SongChartResponse.kt new file mode 100644 index 0000000..57f83e5 --- /dev/null +++ b/src/main/kotlin/com/stevesoltys/applemusic/model/track/song/SongChartResponse.kt @@ -0,0 +1,7 @@ +package com.stevesoltys.applemusic.model.track.song + +class SongChartResponse( + val chart: String, + val name: String, + data: Array = emptyArray() +) : SongResponse(data) \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/model/track/song/SongResponse.kt b/src/main/kotlin/com/stevesoltys/applemusic/model/track/song/SongResponse.kt new file mode 100644 index 0000000..f14778a --- /dev/null +++ b/src/main/kotlin/com/stevesoltys/applemusic/model/track/song/SongResponse.kt @@ -0,0 +1,10 @@ +package com.stevesoltys.applemusic.model.track.song + +import com.stevesoltys.applemusic.model.ResponseRoot + +/** + * @author Steve Soltys + */ +open class SongResponse( + val data: Array = emptyArray() +) : ResponseRoot() \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/net/AppleMusicObjectMapper.kt b/src/main/kotlin/com/stevesoltys/applemusic/net/AppleMusicObjectMapper.kt index 322a584..8b23845 100644 --- a/src/main/kotlin/com/stevesoltys/applemusic/net/AppleMusicObjectMapper.kt +++ b/src/main/kotlin/com/stevesoltys/applemusic/net/AppleMusicObjectMapper.kt @@ -2,6 +2,7 @@ package com.stevesoltys.applemusic.net import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule /** * @author Steve Soltys @@ -9,5 +10,10 @@ import com.fasterxml.jackson.databind.ObjectMapper class AppleMusicObjectMapper : ObjectMapper() { init { configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + registerModule( + KotlinModule.Builder() + .build() + ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/stevesoltys/applemusic/net/AppleMusicService.kt b/src/main/kotlin/com/stevesoltys/applemusic/net/AppleMusicService.kt index 0ddb900..9a924ef 100644 --- a/src/main/kotlin/com/stevesoltys/applemusic/net/AppleMusicService.kt +++ b/src/main/kotlin/com/stevesoltys/applemusic/net/AppleMusicService.kt @@ -2,6 +2,7 @@ package com.stevesoltys.applemusic.net import com.stevesoltys.applemusic.model.album.AlbumResponse import com.stevesoltys.applemusic.model.artist.ArtistResponse +import com.stevesoltys.applemusic.model.chart.ChartResponse import com.stevesoltys.applemusic.model.search.SearchResponse import retrofit2.Call import retrofit2.http.GET @@ -21,6 +22,17 @@ interface AppleMusicService { @Query("include") types: Array? = null ): Call + @GET("charts") + fun getCatalogCharts( + @Query("types") types: Array, + @Query("l") localization: String? = null, + @Query("chart") chart: String? = null, + @Query("offset") offset: String? = null, + @Query("limit") limit: Int? = null, + @Query("genre") genre: String? = null, + @Query("with") with: Array? = null + ): Call + @GET("artists/{id}") fun getArtistById( @Path("id") id: String, @@ -28,7 +40,9 @@ interface AppleMusicService { ): Call @GET("artists") - fun getArtistsById(@Query("ids") ids: Array): Call + fun getArtistsById( + @Query("ids") ids: Array + ): Call @GET("albums/{id}") fun getAlbumById( @@ -37,7 +51,9 @@ interface AppleMusicService { ): Call @GET("albums") - fun getAlbumsById(@Query("ids") ids: Array): Call + fun getAlbumsById( + @Query("ids") ids: Array + ): Call @GET("artists/{id}/albums") fun getAlbumsByArtistId( diff --git a/src/test/kotlin/com/stevesoltys/applemusic/AppleMusicE2ETest.kt b/src/test/kotlin/com/stevesoltys/applemusic/AppleMusicE2ETest.kt new file mode 100644 index 0000000..81d30bf --- /dev/null +++ b/src/test/kotlin/com/stevesoltys/applemusic/AppleMusicE2ETest.kt @@ -0,0 +1,109 @@ +package com.stevesoltys.applemusic + +import com.stevesoltys.applemusic.model.chart.ChartResultType +import com.stevesoltys.applemusic.model.chart.ChartType +import com.stevesoltys.applemusic.model.search.SearchResultType +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.util.* + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AppleMusicE2ETest { + + companion object { + private val TEAM_ID = System.getenv("TEAM_ID") + private val PRIVATE_KEY_BASE64 = System.getenv("PRIVATE_KEY_BASE64") + private val KEY_ID = System.getenv("KEY_ID") + private const val STOREFRONT = "us" + + private const val TEST_ARTIST_IDENTIFIER = "485953" + private const val TEST_ARTIST_NAME = "Billy Joel" + + private const val TEST_ALBUM_IDENTIFIER = "259814641" + private const val TEST_ALBUM_NAME = "An Innocent Man" + } + + private lateinit var appleMusic: AppleMusic + + @BeforeAll + fun `set up`() { + appleMusic = AppleMusic( + teamId = TEAM_ID, + privateKey = Base64.getDecoder().decode(PRIVATE_KEY_BASE64), + keyId = KEY_ID, + storefront = STOREFRONT + ) + } + + @Test + fun `can search for artists`() { + val result = appleMusic.search( + TEST_ARTIST_NAME, + types = setOf(SearchResultType.ARTISTS) + ) + + val artists = result.results?.artists?.data + artists shouldNotBe null + artists!!.size shouldBeGreaterThan 0 + } + + @Test + fun `can get artist by identifier`() { + val result = appleMusic.getArtistById(TEST_ARTIST_IDENTIFIER) + + val billyJoelAttributes = result.data.firstOrNull()?.attributes + billyJoelAttributes.shouldNotBeNull() + billyJoelAttributes.name shouldBe TEST_ARTIST_NAME + } + + @Test + fun `can get artist albums by identifier`() { + val result = appleMusic.getAllAlbumsByArtistId(TEST_ARTIST_IDENTIFIER) + + val albums = result.data + albums.shouldNotBeNull().shouldNotBeEmpty() + } + + @Test + fun `can get album by identifier`() { + val result = appleMusic.getAlbumById(TEST_ALBUM_IDENTIFIER) + + val artistAttributes = result.data.firstOrNull()?.attributes + artistAttributes.shouldNotBeNull() + artistAttributes.name shouldBe TEST_ALBUM_NAME + } + + @Test + fun `can get top 100 album charts`() { + val result = appleMusic.getCatalogCharts( + types = setOf(ChartResultType.ALBUMS), + with = setOf(ChartType.DAILY_GLOBAL_TOP_CHARTS), + limit = 100 + ) + + result.results.albums.shouldNotBeEmpty() + result.results.albums.first().data.shouldHaveSize(100) + result.results.songs.shouldBeEmpty() + } + + @Test + fun `can get top 100 song charts`() { + val result = appleMusic.getCatalogCharts( + types = setOf(ChartResultType.SONGS), + with = setOf(ChartType.DAILY_GLOBAL_TOP_CHARTS), + limit = 100 + ) + + result.results.songs.shouldNotBeEmpty() + result.results.songs.first().data.shouldHaveSize(100) + result.results.albums.shouldBeEmpty() + } +} \ No newline at end of file From 80d262ed2f5d8be13063be570a9674f9ecbf73f8 Mon Sep 17 00:00:00 2001 From: Steve Soltys Date: Wed, 14 Dec 2022 01:02:30 -0500 Subject: [PATCH 2/3] Fix Gradle build --- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index b9ac75e..adaf30b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id "org.jetbrains.kotlin.jvm" version "1.3.72" + id "org.jetbrains.kotlin.jvm" version "1.6.21" id "application" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ba94df8..070cb70 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 88817d96e851be377e21f43a076867ed32a91ccd Mon Sep 17 00:00:00 2001 From: Steve Soltys Date: Wed, 14 Dec 2022 01:08:23 -0500 Subject: [PATCH 3/3] Fix GitHub workflow build --- .github/workflows/build.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c8a33c..8554ec0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,9 @@ name: Build and test on: - - pull_request - - push + pull_request: + push: + branches: + - master jobs: build: