├── webpack.config.js ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── webpack.config.d └── patch.js ├── src ├── androidMain │ ├── AndroidManifest.xml │ ├── res │ │ └── layout │ │ │ └── spotify_pkce_auth_layout.xml │ └── kotlin │ │ └── com │ │ └── adamratzman │ │ └── spotify │ │ ├── auth │ │ ├── pkce │ │ │ └── PkceAuthUtils.kt │ │ └── implicit │ │ │ ├── AbstractSpotifyAppImplicitLoginActivity.kt │ │ │ ├── AbstractSpotifyAppCompatImplicitLoginActivity.kt │ │ │ ├── ImplicitAuthUtils.kt │ │ │ └── SpotifyImplicitLoginActivity.kt │ │ ├── notifications │ │ ├── SpotifyBroadcastReceiverUtils.kt │ │ └── AbstractSpotifyBroadcastReceiver.kt │ │ └── utils │ │ └── PlatformUtils.kt ├── iosMain │ └── kotlin │ │ └── com.adamratzman.spotify.utils │ │ └── PlatformUtils.kt ├── tvosMain │ └── kotlin │ │ └── com.adamratzman.spotify.utils │ │ └── PlatformUtils.kt ├── linuxX64Main │ └── kotlin │ │ └── com.adamratzman.spotify.utils │ │ └── PlatformUtils.kt ├── macosX64Main │ └── kotlin │ │ └── com.adamratzman.spotify.utils │ │ └── PlatformUtils.kt ├── mingwX64Main │ └── kotlin │ │ └── com.adamratzman.spotify.utils │ │ └── PlatformUtils.kt ├── commonMain │ └── kotlin │ │ ├── com.adamratzman.spotify │ │ ├── utils │ │ │ ├── Platform.kt │ │ │ ├── TimeUnit.kt │ │ │ ├── ConcurrentHashMap.kt │ │ │ ├── Encoding.kt │ │ │ ├── IO.kt │ │ │ ├── Utils.kt │ │ │ └── ExternalUrls.kt │ │ ├── annotations │ │ │ └── ExperimentalAnnotations.kt │ │ ├── models │ │ │ ├── Misc.kt │ │ │ ├── SpotifySearchResult.kt │ │ │ ├── Library.kt │ │ │ ├── Authentication.kt │ │ │ ├── Browse.kt │ │ │ ├── Playable.kt │ │ │ ├── Artists.kt │ │ │ ├── LocalTracks.kt │ │ │ ├── ResultObjects.kt │ │ │ ├── Show.kt │ │ │ └── Users.kt │ │ ├── endpoints │ │ │ ├── pub │ │ │ │ ├── MarketsApi.kt │ │ │ │ ├── UserApi.kt │ │ │ │ ├── FollowingApi.kt │ │ │ │ ├── EpisodeApi.kt │ │ │ │ ├── AlbumApi.kt │ │ │ │ ├── TrackApi.kt │ │ │ │ └── ShowApi.kt │ │ │ └── client │ │ │ │ ├── ClientProfileApi.kt │ │ │ │ ├── ClientEpisodeApi.kt │ │ │ │ ├── ClientShowApi.kt │ │ │ │ └── ClientPersonalizationApi.kt │ │ ├── SpotifyException.kt │ │ ├── SpotifyRestAction.kt │ │ └── SpotifyScope.kt │ │ └── com.soywiz.korim.format.jpg │ │ └── JPEG.kt ├── commonJvmLikeMain │ └── kotlin │ │ └── com │ │ └── adamratzman │ │ └── spotify │ │ ├── utils │ │ └── DateTimeUtils.kt │ │ └── javainterop │ │ └── SpotifyContinuation.kt ├── commonNonJvmTargetsTest │ └── kotlin │ │ └── com.adamratzman.spotify │ │ └── CommonImpl.kt ├── jvmMain │ └── kotlin │ │ └── com │ │ └── adamratzman │ │ └── spotify │ │ └── utils │ │ └── PlatformUtils.kt ├── commonTest │ └── kotlin │ │ └── com.adamratzman │ │ └── spotify │ │ ├── pub │ │ ├── MarketsApiTest.kt │ │ ├── PublicUserApiTest.kt │ │ ├── EpisodeApiTest.kt │ │ ├── PublicFollowingApiTest.kt │ │ ├── ShowApiTest.kt │ │ ├── PublicTracksApiTest.kt │ │ ├── PublicAlbumsApiTest.kt │ │ ├── PublicArtistsApiTest.kt │ │ ├── PublicPlaylistsApiTest.kt │ │ └── SearchApiTest.kt │ │ ├── priv │ │ ├── ClientUserApiTest.kt │ │ ├── ClientPersonalizationApiTest.kt │ │ ├── ClientEpisodeApiTest.kt │ │ └── ClientFollowingApiTest.kt │ │ ├── utilities │ │ ├── RestTests.kt │ │ ├── JsonTests.kt │ │ └── UtilityTests.kt │ │ ├── AbstractTest.kt │ │ └── Common.kt ├── desktopMain │ └── kotlin │ │ └── com │ │ └── adamratzman │ │ └── spotify │ │ └── utils │ │ └── PlatformUtils.kt ├── nativeDarwinMain │ └── kotlin │ │ └── com │ │ └── adamratzman │ │ └── spotify │ │ └── utils │ │ └── PlatformUtils.kt ├── jsMain │ └── kotlin │ │ ├── com │ │ └── adamratzman │ │ │ └── spotify │ │ │ └── utils │ │ │ ├── ImplicitGrant.kt │ │ │ └── PlatformUtils.kt │ │ └── co.scdn.sdk │ │ └── SpotifyPlayerJs.kt ├── jvmTest │ └── kotlin │ │ └── com │ │ └── adamratzman │ │ └── spotify │ │ └── PkceTest.kt └── commonJvmLikeTest │ └── kotlin │ └── com │ └── adamratzman │ └── spotify │ └── CommonImpl.kt ├── publish_all.sh ├── CONTRIBUTING.md ├── gradle.properties ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── FUNDING.yml ├── .github │ └── FUNDING.yml └── workflows │ ├── ci.yml │ ├── ci-client.yml │ └── release.yml ├── LICENSE ├── settings.gradle.kts ├── TESTING.md └── gradlew.bat /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | globalObject: 'this' 4 | } 5 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamint/spotify-web-api-kotlin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /webpack.config.d/patch.js: -------------------------------------------------------------------------------- 1 | config.resolve.alias = { 2 | "crypto": false, 3 | } 4 | 5 | output: { 6 | globalObject: 'this' 7 | } -------------------------------------------------------------------------------- /src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/iosMain/kotlin/com.adamratzman.spotify.utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import kotlinx.coroutines.runBlocking 5 | 6 | public actual fun runBlockingOnJvmAndNative(block: suspend () -> T): T { 7 | return runBlocking { block() } 8 | } 9 | -------------------------------------------------------------------------------- /src/tvosMain/kotlin/com.adamratzman.spotify.utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import kotlinx.coroutines.runBlocking 5 | 6 | public actual fun runBlockingOnJvmAndNative(block: suspend () -> T): T { 7 | return runBlocking { block() } 8 | } 9 | -------------------------------------------------------------------------------- /src/linuxX64Main/kotlin/com.adamratzman.spotify.utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import kotlinx.coroutines.runBlocking 5 | 6 | public actual fun runBlockingOnJvmAndNative(block: suspend () -> T): T { 7 | return runBlocking { block() } 8 | } 9 | -------------------------------------------------------------------------------- /src/macosX64Main/kotlin/com.adamratzman.spotify.utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import kotlinx.coroutines.runBlocking 5 | 6 | public actual fun runBlockingOnJvmAndNative(block: suspend () -> T): T { 7 | return runBlocking { block() } 8 | } 9 | -------------------------------------------------------------------------------- /src/mingwX64Main/kotlin/com.adamratzman.spotify.utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import kotlinx.coroutines.runBlocking 5 | 6 | public actual fun runBlockingOnJvmAndNative(block: suspend () -> T): T { 7 | return runBlocking { block() } 8 | } 9 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/utils/Platform.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | /** 5 | * Actual platforms that this program can be run on. 6 | */ 7 | public enum class Platform { 8 | Jvm, 9 | Android, 10 | Js, 11 | Native 12 | } 13 | 14 | public expect val currentApiPlatform: Platform 15 | -------------------------------------------------------------------------------- /publish_all.sh: -------------------------------------------------------------------------------- 1 | gradle publishMacosX64PublicationToNexusRepository publishLinuxX64PublicationToNexusRepository publishKotlinMultiplatformPublicationToNexusRepository publishTvosX64PublicationToNexusRepository publishTvosArm64PublicationToNexusRepository publishIosX64PublicationToNexusRepository publishIosArm64PublicationToNexusRepository publishJsPublicationToNexusRepository publishJvmPublicationToNexusRepository publishAndroidPublicationToNexusRepository -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/utils/TimeUnit.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | /** 5 | * Represents a unit of time 6 | */ 7 | public enum class TimeUnit(private val multiplier: Long) { 8 | Milliseconds(1), 9 | Seconds(1000), 10 | Minutes(60000); 11 | 12 | public fun toMillis(duration: Long): Long = duration * multiplier 13 | } 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, feel free to first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | However, any library additions are always welcome. I am especially looking for the addition of new Kotlin/Native 7 | targets. 8 | 9 | ## Testing 10 | Please see [testing.md](TESTING.md) for full testing instructions. Your contributions should be able to pass every test. -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/annotations/ExperimentalAnnotations.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.annotations 3 | 4 | @Retention(AnnotationRetention.BINARY) 5 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) 6 | public annotation class SpotifyExperimentalHttpApi 7 | 8 | @Retention(AnnotationRetention.BINARY) 9 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) 10 | public annotation class SpotifyExperimentalFunctionApi 11 | -------------------------------------------------------------------------------- /src/commonJvmLikeMain/kotlin/com/adamratzman/spotify/utils/DateTimeUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import kotlinx.datetime.Instant 5 | 6 | /** 7 | * The current time in milliseconds since UNIX epoch. 8 | */ 9 | public actual fun getCurrentTimeMs(): Long = System.currentTimeMillis() 10 | 11 | /** 12 | * Format date to ISO 8601 format 13 | */ 14 | internal actual fun formatDate(date: Long): String { 15 | return Instant.fromEpochMilliseconds(date).toString() 16 | } 17 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/utils/ConcurrentHashMap.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | public expect class ConcurrentHashMap() { 5 | public operator fun get(key: K): V? 6 | public fun put(key: K, value: V): V? 7 | public fun remove(key: K): V? 8 | public fun clear() 9 | 10 | public val size: Int 11 | public val entries: MutableSet> 12 | } 13 | 14 | public expect fun ConcurrentHashMap.asList(): List> 15 | -------------------------------------------------------------------------------- /src/androidMain/res/layout/spotify_pkce_auth_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/utils/Encoding.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import com.soywiz.krypto.encoding.Base64 5 | import io.ktor.utils.io.core.toByteArray 6 | 7 | internal fun String.base64ByteEncode() = Base64.encode(toByteArray()) 8 | 9 | public fun String.urlEncodeBase64String(): String { 10 | var result = this 11 | while (result.endsWith("=")) result = result.removeSuffix("=") 12 | 13 | return result.replace("/", "_").replace("+", "-") 14 | } 15 | 16 | internal expect fun String.encodeUrl(): String 17 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | systemProp.org.gradle.internal.publish.checksums.insecure=true 2 | 3 | org.gradle.daemon=true 4 | org.gradle.jvmargs=-Xmx8000m 5 | org.gradle.caching=true 6 | 7 | # android target settings 8 | android.useAndroidX=true 9 | android.enableJetifier=true 10 | 11 | # language dependencies 12 | kotlinVersion=1.9.22 13 | 14 | # library dependencies 15 | kotlinxDatetimeVersion=0.5.0 16 | kotlinxSerializationVersion=1.6.2 17 | ktorVersion=2.3.8 18 | korlibsVersion=3.4.0 19 | kotlinxCoroutinesVersion=1.7.3 20 | 21 | androidBuildToolsVersion=8.2.2 22 | androidSpotifyAuthVersion=2.1.1 23 | androidCryptoVersion=1.1.0-alpha06 24 | androidxCompatVersion=1.7.0-alpha03 25 | 26 | sparkVersion=2.9.4 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/commonNonJvmTargetsTest/kotlin/com.adamratzman.spotify/CommonImpl.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify 3 | 4 | actual fun areLivePkceTestsEnabled(): Boolean = false 5 | actual fun arePlayerTestsEnabled(): Boolean = false 6 | actual fun isHttpLoggingEnabled(): Boolean = false 7 | actual fun getTestTokenString(): String? = null 8 | actual fun getTestRedirectUri(): String? = null 9 | actual fun getTestClientId(): String? = null 10 | actual fun getTestClientSecret(): String? = null 11 | actual fun getResponseCacher(): ResponseCacher? = null 12 | 13 | actual suspend fun buildSpotifyApi(testClassQualifiedName: String, testName: String): GenericSpotifyApi? = null 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: adamint # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: adamratzman # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: adamint # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: adamratzman # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/com/adamratzman/spotify/utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import kotlinx.coroutines.runBlocking 5 | import java.net.URLEncoder 6 | 7 | internal actual fun String.encodeUrl() = URLEncoder.encode(this, "UTF-8")!! 8 | 9 | /** 10 | * The actual platform that this program is running on. 11 | */ 12 | public actual val currentApiPlatform: Platform = Platform.Jvm 13 | 14 | public actual typealias ConcurrentHashMap = java.util.concurrent.ConcurrentHashMap 15 | 16 | public actual fun ConcurrentHashMap.asList(): List> = toList() 17 | 18 | public actual fun runBlockingOnJvmAndNative(block: suspend () -> T): T { 19 | return runBlocking { block() } 20 | } 21 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/Misc.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * A Spotify image 8 | * 9 | * @param height The image height in pixels. If unknown: null or not returned. 10 | * @param url The source URL of the image. 11 | * @param width The image width in pixels. If unknown: null or not returned. 12 | */ 13 | @Serializable 14 | public data class SpotifyImage( 15 | val height: Double? = null, 16 | val url: String, 17 | val width: Double? = null 18 | ) 19 | 20 | /** 21 | * Contains an explanation of why a track is not available 22 | * 23 | * @param reason why the track is not available 24 | */ 25 | @Serializable 26 | public data class Restrictions(val reason: String) 27 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/MarketsApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.test.TestResult 11 | import kotlin.test.Test 12 | import kotlin.test.assertTrue 13 | 14 | class MarketsApiTest : AbstractTest() { 15 | @Test 16 | fun testGetAvailableMarkets(): TestResult = runTestOnDefaultDispatcher { 17 | buildApi(::testGetAvailableMarkets.name) 18 | assertTrue(api.markets.getAvailableMarkets().isNotEmpty()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/priv/ClientUserApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.priv 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.SpotifyClientApi 8 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.test.TestResult 11 | import kotlin.test.Test 12 | 13 | class ClientUserApiTest : AbstractTest() { 14 | @Test 15 | fun testClientProfile(): TestResult = runTestOnDefaultDispatcher { 16 | buildApi(::testClientProfile.name) 17 | if (!isApiInitialized()) return@runTestOnDefaultDispatcher 18 | 19 | api.users.getClientProfile().displayName 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/desktopMain/kotlin/com/adamratzman/spotify/utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import io.ktor.http.encodeURLQueryComponent 5 | import kotlinx.datetime.Clock 6 | import kotlinx.datetime.Instant 7 | 8 | internal actual fun String.encodeUrl() = encodeURLQueryComponent() 9 | 10 | /** 11 | * Actual platform that this program is run on. 12 | */ 13 | public actual val currentApiPlatform: Platform = Platform.Native 14 | 15 | public actual typealias ConcurrentHashMap = HashMap 16 | 17 | public actual fun ConcurrentHashMap.asList(): List> = toList() 18 | 19 | /** 20 | * The current time in milliseconds since UNIX epoch. 21 | */ 22 | public actual fun getCurrentTimeMs(): Long = Clock.System.now().toEpochMilliseconds() 23 | 24 | /** 25 | * Format date to ISO 8601 format 26 | */ 27 | internal actual fun formatDate(date: Long): String { 28 | return Instant.fromEpochMilliseconds(date).toString() 29 | } 30 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/PublicUserApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 9 | import com.adamratzman.spotify.utils.catch 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.test.TestResult 12 | import kotlin.test.Test 13 | import kotlin.test.assertNull 14 | import kotlin.test.assertTrue 15 | 16 | class PublicUserApiTest : AbstractTest() { 17 | @Test 18 | fun testPublicUser(): TestResult = runTestOnDefaultDispatcher { 19 | buildApi(::testPublicUser.name) 20 | 21 | assertTrue(catch { api.users.getProfile("adamratzman1")!!.followers.total } != null) 22 | assertNull(api.users.getProfile("ejwkfjwkerfjkwerjkfjkwerfjkjksdfjkasdf")) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/nativeDarwinMain/kotlin/com/adamratzman/spotify/utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import io.ktor.http.encodeURLQueryComponent 5 | import kotlinx.datetime.Clock 6 | import kotlinx.datetime.Instant 7 | 8 | internal actual fun String.encodeUrl() = encodeURLQueryComponent() 9 | 10 | /** 11 | * Actual platform that this program is run on. 12 | */ 13 | public actual val currentApiPlatform: Platform = Platform.Native 14 | 15 | public actual typealias ConcurrentHashMap = HashMap 16 | 17 | public actual fun ConcurrentHashMap.asList(): List> = toList() 18 | 19 | /** 20 | * The current time in milliseconds since UNIX epoch. 21 | */ 22 | public actual fun getCurrentTimeMs(): Long = Clock.System.now().toEpochMilliseconds() 23 | 24 | /** 25 | * Format date to ISO 8601 format 26 | */ 27 | internal actual fun formatDate(date: Long): String { 28 | return Instant.fromEpochMilliseconds(date).toString() 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Test Workflow 2 | 3 | on: 4 | push: 5 | branches: [ main, dev, dev/** ] 6 | pull_request: 7 | branches: [ main, dev, dev/** ] 8 | 9 | jobs: 10 | test_android_jvm_linux_trusted: 11 | runs-on: ubuntu-latest 12 | environment: testing 13 | env: 14 | SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} 15 | SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} 16 | steps: 17 | - name: Check out repo 18 | uses: actions/checkout@v2 19 | - name: Install java 11 20 | uses: actions/setup-java@v2 21 | with: 22 | distribution: 'adopt' 23 | java-version: '17' 24 | - name: Install curl 25 | run: sudo apt-get install -y curl libcurl4-openssl-dev 26 | - name: Test android 27 | run: ./gradlew testDebugUnitTest 28 | - name: Test jvm 29 | run: ./gradlew jvmTest 30 | - name: Archive test results 31 | uses: actions/upload-artifact@v2 32 | if: always() 33 | with: 34 | name: code-coverage-report 35 | path: build/reports 36 | -------------------------------------------------------------------------------- /src/androidMain/kotlin/com/adamratzman/spotify/auth/pkce/PkceAuthUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.auth.pkce 3 | 4 | import android.app.Activity 5 | import android.content.Intent 6 | import android.os.Build 7 | import androidx.annotation.RequiresApi 8 | 9 | public fun Intent?.isSpotifyPkceAuthIntent(redirectUri: String): Boolean { 10 | return this != null && 11 | (dataString?.startsWith("$redirectUri/?code=") == true || dataString?.startsWith("$redirectUri/?error=") == true) 12 | } 13 | 14 | /** 15 | * Start Spotify PKCE login activity within an existing activity. 16 | * 17 | * @param spotifyLoginImplementationClass Your implementation of [AbstractSpotifyPkceLoginActivity], defining what to do on Spotify PKCE login 18 | */ 19 | @RequiresApi(Build.VERSION_CODES.M) 20 | public fun Activity.startSpotifyClientPkceLoginActivity(spotifyLoginImplementationClass: Class) { 21 | val intent = Intent(this, spotifyLoginImplementationClass) 22 | startActivity(intent) 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Adam Ratzman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/utils/IO.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import com.soywiz.korim.bitmap.Bitmap 5 | import com.soywiz.korim.format.jpg.JPEG 6 | import com.soywiz.korio.file.VfsFile 7 | import com.soywiz.korio.file.std.UrlVfs 8 | import com.soywiz.korio.file.std.localVfs 9 | import com.soywiz.krypto.encoding.Base64 10 | 11 | /** 12 | * Represents an image. Please use convertXToBufferedImage and convertBufferedImageToX methods to read and write [BufferedImage] 13 | */ 14 | public typealias BufferedImage = Bitmap 15 | 16 | public fun convertBufferedImageToBase64JpegString(image: BufferedImage): String { 17 | return Base64.encode(JPEG.encode(image)) 18 | } 19 | 20 | public suspend fun convertUrlPathToBufferedImage(url: String): BufferedImage { 21 | return JPEG.decode(UrlVfs(url)) 22 | } 23 | 24 | public suspend fun convertLocalImagePathToBufferedImage(path: String): BufferedImage { 25 | return JPEG.decode(localVfs(path)) 26 | } 27 | 28 | public suspend fun convertFileToBufferedImage(file: VfsFile): BufferedImage = JPEG.decode(file.readBytes()) 29 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/pub/MarketsApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.pub 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.http.SpotifyEndpoint 6 | import com.adamratzman.spotify.models.serialization.toInnerArray 7 | import com.adamratzman.spotify.utils.Market 8 | import kotlinx.serialization.builtins.ListSerializer 9 | import kotlinx.serialization.builtins.serializer 10 | 11 | public class MarketsApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) { 12 | /** 13 | * Get the list of markets where Spotify is available. 14 | * 15 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/#category-markets)** 16 | * 17 | * @return List of [Market] 18 | */ 19 | public suspend fun getAvailableMarkets(): List { 20 | return get(endpointBuilder("/markets").toString()).toInnerArray( 21 | ListSerializer(String.serializer()), 22 | "markets", 23 | json 24 | ).map { Market.valueOf(it.uppercase()) } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/androidMain/kotlin/com/adamratzman/spotify/notifications/SpotifyBroadcastReceiverUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.notifications 3 | 4 | import android.app.Activity 5 | import android.content.Context 6 | import android.content.IntentFilter 7 | import androidx.fragment.app.Fragment 8 | 9 | /** 10 | * Register a Spotify broadcast receiver (receiving notifications from the Spotify app) for the specified notification types. 11 | * 12 | * This should be used in a [Fragment] or [Activity]. 13 | * 14 | * Note that "Device Broadcast Status" must be enabled in the Spotify app and the active Spotify device must be the Android 15 | * device that your app is on to receive notifications. 16 | * 17 | * @param receiver An instance of your implementation of [AbstractSpotifyBroadcastReceiver] 18 | * @param notificationTypes The notification types that you would like to subscribe to. 19 | */ 20 | public fun Context.registerSpotifyBroadcastReceiver( 21 | receiver: AbstractSpotifyBroadcastReceiver, 22 | vararg notificationTypes: SpotifyBroadcastType 23 | ) { 24 | val filter = IntentFilter() 25 | notificationTypes.forEach { filter.addAction(it.id) } 26 | 27 | registerReceiver(receiver, filter) 28 | } 29 | -------------------------------------------------------------------------------- /src/commonJvmLikeMain/kotlin/com/adamratzman/spotify/javainterop/SpotifyContinuation.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:JvmName("SpotifyContinuation") 3 | 4 | package com.adamratzman.spotify.javainterop 5 | 6 | import kotlin.coroutines.Continuation 7 | import kotlin.coroutines.CoroutineContext 8 | import kotlin.coroutines.EmptyCoroutineContext 9 | 10 | /** 11 | * A [Continuation] wrapper to allow you to directly implement [onSuccess] and [onFailure], when exceptions are hidden 12 | * on JVM via traditional continuations. **Please use this class as a callback anytime you are using Java code with this library.** 13 | * 14 | */ 15 | public abstract class SpotifyContinuation : Continuation { 16 | /** 17 | * Invoke a function with the callback [value] 18 | * 19 | * @param value The value retrieved from the Spotify API. 20 | */ 21 | public abstract fun onSuccess(value: T) 22 | 23 | /** 24 | * Handle exceptions during this API call. 25 | * 26 | * @param exception The exception that was thrown during the call. 27 | */ 28 | public abstract fun onFailure(exception: Throwable) 29 | 30 | override fun resumeWith(result: Result) { 31 | result.fold(::onSuccess, ::onFailure) 32 | } 33 | 34 | override val context: CoroutineContext = EmptyCoroutineContext 35 | } 36 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/com/adamratzman/spotify/utils/ImplicitGrant.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:Suppress("unused") 3 | 4 | package com.adamratzman.spotify.utils 5 | 6 | import com.adamratzman.spotify.SpotifyImplicitGrantApi 7 | import com.adamratzman.spotify.models.Token 8 | import kotlinx.browser.window 9 | import org.w3c.dom.url.URLSearchParams 10 | 11 | /** 12 | * Parse the current url into a valid [Token] to be used when instantiating a new [SpotifyImplicitGrantApi] 13 | */ 14 | public fun parseSpotifyCallbackHashToToken(): Token = parseSpotifyCallbackHashToToken(window.location.hash.substring(1)) 15 | 16 | /** 17 | * Parse the hash string into a valid [Token] to be used when instantiating a new [SpotifyImplicitGrantApi] 18 | * 19 | * @param hashString The Spotify hash string containing access_token, token_type, and expires_in. 20 | */ 21 | public fun parseSpotifyCallbackHashToToken(hashString: String): Token { 22 | val hash = URLSearchParams(hashString) 23 | 24 | return Token( 25 | hash.get("access_token") ?: throw IllegalStateException("access_token is not part of the hash!"), 26 | hash.get("token_type") ?: throw IllegalStateException("token_type is not part of the hash!"), 27 | hash.get("expires_in")?.toIntOrNull() ?: throw IllegalStateException("expires_in is not part of the hash!") 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/com/adamratzman/spotify/utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import com.adamratzman.spotify.SpotifyRestAction 5 | import io.ktor.http.encodeURLQueryComponent 6 | import kotlinx.coroutines.GlobalScope 7 | import kotlinx.coroutines.promise 8 | import kotlin.js.Date 9 | import kotlin.js.Promise 10 | 11 | internal actual fun String.encodeUrl() = encodeURLQueryComponent() 12 | 13 | /** 14 | * Actual platform that this program is run on. 15 | */ 16 | public actual val currentApiPlatform: Platform = Platform.Js 17 | 18 | public actual typealias ConcurrentHashMap = HashMap 19 | 20 | public actual fun ConcurrentHashMap.asList(): List> = toList() 21 | 22 | public actual fun runBlockingOnJvmAndNative(block: suspend () -> T): T { 23 | throw IllegalStateException("JS does not have runBlocking") 24 | } 25 | 26 | public fun SpotifyRestAction.asPromise(): Promise = GlobalScope.promise { 27 | supplier() 28 | } 29 | 30 | /** 31 | * The current time in milliseconds since UNIX epoch. 32 | */ 33 | public actual fun getCurrentTimeMs(): Long = Date.now().toLong() 34 | 35 | /** 36 | * Format date to ISO 8601 format 37 | */ 38 | internal actual fun formatDate(date: Long): String { 39 | return Date(date).toISOString() 40 | } 41 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/SpotifySearchResult.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Available filters that Spotify allows in search, in addition to filtering by object type. 8 | */ 9 | public enum class SearchFilterType(public val id: String) { 10 | Album("album"), 11 | Artist("artist"), 12 | Track("track"), 13 | Year("year"), 14 | Upc("upc"), 15 | Hipster("tag:hipster"), 16 | New("tag:new"), 17 | Isrc("isrc"), 18 | Genre("genre") 19 | } 20 | 21 | /** 22 | * A filter of type [SearchFilterType]. Should be unique by type. 23 | * 24 | * @param filterValue A string to match, or in the case of [SearchFilterType.Year] can be a range of years in the form 25 | * A-B. Example: 2000-2010 26 | */ 27 | public data class SearchFilter(val filterType: SearchFilterType, val filterValue: String) 28 | 29 | @Serializable 30 | public data class SpotifySearchResult( 31 | val albums: PagingObject? = null, 32 | val artists: PagingObject? = null, 33 | val playlists: PagingObject? = null, 34 | val tracks: PagingObject? = null, 35 | val episodes: NullablePagingObject? = null, 36 | val shows: NullablePagingObject? = null 37 | ) 38 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/utils/Utils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import com.adamratzman.spotify.SpotifyException 5 | import com.adamratzman.spotify.models.ResultEnum 6 | import kotlinx.serialization.json.JsonElement 7 | 8 | /** 9 | * The current time in milliseconds since UNIX epoch. 10 | */ 11 | public expect fun getCurrentTimeMs(): Long 12 | 13 | /** 14 | * Format date to ISO 8601 format 15 | */ 16 | internal expect fun formatDate(date: Long): String 17 | 18 | internal fun jsonMap(vararg pairs: Pair) = pairs.toMap().toMutableMap() 19 | 20 | internal suspend inline fun catch(catchInternalServerError: Boolean = false, crossinline function: suspend () -> T): T? { 21 | return try { 22 | function() 23 | } catch (e: SpotifyException.BadRequestException) { 24 | if (e.statusCode !in listOf(400, 404)) throw e 25 | else if (e.statusCode in 500..599 && catchInternalServerError) throw e 26 | 27 | // we should only ignore the exception if it's 400 or 404. Otherwise, it's a larger issue 28 | null 29 | } 30 | } 31 | 32 | internal fun Array.match(identifier: String) = 33 | firstOrNull { it.retrieveIdentifier().toString().equals(identifier, true) } 34 | 35 | public expect fun runBlockingOnJvmAndNative(block: suspend () -> T): T 36 | -------------------------------------------------------------------------------- /.github/workflows/ci-client.yml: -------------------------------------------------------------------------------- 1 | name: CI Client Test Workflow 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | spotify_test_client_token: 6 | description: 'Spotify client redirect token (for client tests before release)' 7 | required: true 8 | spotify_test_redirect_uri: 9 | description: 'Spotify redirect uri' 10 | required: true 11 | env: 12 | SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} 13 | SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} 14 | SPOTIFY_TOKEN_STRING: ${{ github.event.inputs.spotify_test_client_token }} 15 | SPOTIFY_REDIRECT_URI: ${{ github.event.inputs.spotify_test_redirect_uri }} 16 | jobs: 17 | verify_client_android_jvm_linux_js: 18 | runs-on: ubuntu-latest 19 | environment: release 20 | steps: 21 | - name: Check out repo 22 | uses: actions/checkout@v2 23 | - name: Install java 11 24 | uses: actions/setup-java@v2 25 | with: 26 | distribution: 'adopt' 27 | java-version: '17' 28 | - name: Install curl 29 | run: sudo apt-get install -y curl libcurl4-openssl-dev 30 | - name: Verify Android 31 | run: ./gradlew testDebugUnitTest 32 | - name: Verify JVM/JS 33 | run: ./gradlew jvmTest 34 | - name: Archive test results 35 | uses: actions/upload-artifact@v2 36 | with: 37 | name: code-coverage-report 38 | path: build/reports 39 | if: always() -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.soywiz.korim.format.jpg/JPEG.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.soywiz.korim.format.jpg 3 | 4 | import com.soywiz.korim.format.ImageData 5 | import com.soywiz.korim.format.ImageDecodingProps 6 | import com.soywiz.korim.format.ImageEncodingProps 7 | import com.soywiz.korim.format.ImageFormat 8 | import com.soywiz.korim.format.ImageFrame 9 | import com.soywiz.korim.format.ImageInfo 10 | import com.soywiz.korio.stream.SyncStream 11 | import com.soywiz.korio.stream.readAll 12 | import com.soywiz.korio.stream.writeBytes 13 | 14 | public object JPEG : ImageFormat("jpg", "jpeg") { 15 | override fun decodeHeader(s: SyncStream, props: ImageDecodingProps): ImageInfo? = try { 16 | val info = JPEGDecoder.decodeInfo(s.readAll()) 17 | ImageInfo().apply { 18 | this.width = info.width 19 | this.height = info.height 20 | this.bitsPerPixel = 24 21 | } 22 | } catch (e: Throwable) { 23 | null 24 | } 25 | 26 | override fun readImage(s: SyncStream, props: ImageDecodingProps): ImageData { 27 | @Suppress("DEPRECATION") 28 | return ImageData(listOf(ImageFrame(JPEGDecoder.decode(s.readAll())))) 29 | } 30 | 31 | override fun writeImage(image: ImageData, s: SyncStream, props: ImageEncodingProps) { 32 | s.writeBytes(JPEGEncoder.encode(image.mainBitmap.toBMP32(), quality = (props.quality * 100).toInt())) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/priv/ClientPersonalizationApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.priv 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.SpotifyClientApi 8 | import com.adamratzman.spotify.endpoints.client.ClientPersonalizationApi 9 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.test.TestResult 12 | import kotlin.test.Test 13 | import kotlin.test.assertTrue 14 | 15 | class ClientPersonalizationApiTest : AbstractTest() { 16 | @Test 17 | fun testGetTopArtists(): TestResult = runTestOnDefaultDispatcher { 18 | buildApi(::testGetTopArtists.name) 19 | if (!isApiInitialized()) return@runTestOnDefaultDispatcher 20 | 21 | assertTrue( 22 | api.personalization.getTopArtists( 23 | 5, 24 | timeRange = ClientPersonalizationApi.TimeRange.MediumTerm 25 | ).items.isNotEmpty() 26 | ) 27 | } 28 | 29 | @Test 30 | fun testGetTopTracks(): TestResult = runTestOnDefaultDispatcher { 31 | buildApi(::testGetTopTracks.name) 32 | if (!isApiInitialized()) return@runTestOnDefaultDispatcher 33 | 34 | assertTrue(api.personalization.getTopTracks(5).items.isNotEmpty()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/androidMain/kotlin/com/adamratzman/spotify/auth/implicit/AbstractSpotifyAppImplicitLoginActivity.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.auth.implicit 3 | 4 | import android.app.Activity 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import androidx.appcompat.app.AppCompatActivity 8 | import com.spotify.sdk.android.auth.LoginActivity 9 | 10 | /** 11 | * Wrapper around spotify-auth's [LoginActivity] that allows configuration of the authentication process, along with 12 | * callbacks on successful and failed authentication. Pair this with [SpotifyDefaultCredentialStore] to easily store credentials. 13 | * Inherits from [Activity]. If instead you want to inherit from [AppCompatActivity], please use [AbstractSpotifyAppCompatImplicitLoginActivity]. 14 | * 15 | */ 16 | public abstract class AbstractSpotifyAppImplicitLoginActivity : SpotifyImplicitLoginActivity, Activity() { 17 | @Suppress("LeakingThis") 18 | public override val activity: Activity = this 19 | public override val useDefaultRedirectHandler: Boolean = true 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | 24 | triggerLoginActivity() 25 | } 26 | 27 | override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { 28 | super.onActivityResult(requestCode, resultCode, intent) 29 | processActivityResult(requestCode, resultCode, intent) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/androidMain/kotlin/com/adamratzman/spotify/auth/implicit/AbstractSpotifyAppCompatImplicitLoginActivity.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.auth.implicit 3 | 4 | import android.app.Activity 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import androidx.appcompat.app.AppCompatActivity 8 | 9 | /** 10 | * Wrapper around spotify-auth's [LoginActivity] that allows configuration of the authentication process, along with 11 | * callbacks on successful and failed authentication. Pair this with [SpotifyDefaultCredentialStore] to easily store credentials. 12 | * Inherits from [AppCompatActivity]. If instead you want to inherit from [Activity], please use [AbstractSpotifyAppImplicitLoginActivity]. 13 | * 14 | */ 15 | public abstract class AbstractSpotifyAppCompatImplicitLoginActivity : SpotifyImplicitLoginActivity, 16 | AppCompatActivity() { 17 | @Suppress("LeakingThis") 18 | public override val activity: Activity = this 19 | public override val useDefaultRedirectHandler: Boolean = true 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | 24 | triggerLoginActivity() 25 | } 26 | 27 | @Suppress("OVERRIDE_DEPRECATION") 28 | override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { 29 | super.onActivityResult(requestCode, resultCode, intent) 30 | processActivityResult(requestCode, resultCode, intent) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | val kotlinVersion: String by settings 3 | val androidBuildToolsVersion: String by settings 4 | 5 | plugins { 6 | id("org.jetbrains.kotlin.multiplatform").version(kotlinVersion) 7 | id("org.jetbrains.kotlin.plugin.serialization").version(kotlinVersion) 8 | id("org.jetbrains.dokka").version(kotlinVersion) 9 | } 10 | 11 | resolutionStrategy { 12 | eachPlugin { 13 | if (requested.id.id == "kotlin-multiplatform") { 14 | useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") 15 | } 16 | if (requested.id.id == "org.jetbrains.kotlin.jvm") { 17 | useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") 18 | } 19 | if (requested.id.id == "kotlinx-serialization") { 20 | useModule("org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion") 21 | } else if (requested.id.id == "com.android.library") { 22 | useModule("com.android.tools.build:gradle:$androidBuildToolsVersion") 23 | } else if (requested.id.id == "kotlin-android-extensions") { 24 | useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") 25 | } 26 | } 27 | } 28 | 29 | repositories { 30 | mavenCentral() 31 | gradlePluginPortal() 32 | google() 33 | maven { url = java.net.URI("https://plugins.gradle.org/m2/") } 34 | } 35 | } 36 | 37 | rootProject.name = "spotify-api-kotlin" -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/pub/UserApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.pub 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.http.SpotifyEndpoint 6 | import com.adamratzman.spotify.models.SpotifyPublicUser 7 | import com.adamratzman.spotify.models.UserUri 8 | import com.adamratzman.spotify.models.serialization.toObject 9 | import com.adamratzman.spotify.utils.catch 10 | import com.adamratzman.spotify.utils.encodeUrl 11 | 12 | /** 13 | * Endpoints for retrieving information about a user’s profile. 14 | * 15 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/users-profile/)** 16 | */ 17 | public open class UserApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) { 18 | /** 19 | * Get public profile information about a Spotify user. 20 | * 21 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/users-profile/get-users-profile/)** 22 | * 23 | * @param user The user’s Spotify user ID. 24 | * 25 | * @return All publicly-available information about the user 26 | */ 27 | public suspend fun getProfile(user: String): SpotifyPublicUser? = catch(/* some incorrect user ids will return 500 */ catchInternalServerError = true) { 28 | get(endpointBuilder("/users/${UserUri(user).id.encodeUrl()}").toString()) 29 | .toObject(SpotifyPublicUser.serializer(), api, json) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/Library.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | /** 8 | * Represents an episode saved in a user's library 9 | * 10 | * @param addedAt The date and time the album was saved. 11 | * @param episode Information about the episode. 12 | */ 13 | @Serializable 14 | public data class SavedEpisode( 15 | @SerialName("added_at") val addedAt: String, 16 | val episode: Episode 17 | ) 18 | 19 | /** 20 | * Represents a show saved in a user's library 21 | * 22 | * @param addedAt The date and time the album was saved. 23 | * @param show Information about the show. 24 | */ 25 | @Serializable 26 | public data class SavedShow( 27 | @SerialName("added_at") val addedAt: String, 28 | val show: SimpleShow 29 | ) 30 | 31 | /** 32 | * Represents an album saved in a user's library 33 | * 34 | * @param addedAt The date and time the album was saved. 35 | * @param album Information about the album. 36 | */ 37 | @Serializable 38 | public data class SavedAlbum( 39 | @SerialName("added_at") val addedAt: String, 40 | val album: Album 41 | ) 42 | 43 | /** 44 | * Represents a track saved in a user's library 45 | * 46 | * @param addedAt The date and time the track was saved. 47 | * @param track The track object. 48 | */ 49 | @Serializable 50 | public data class SavedTrack( 51 | @SerialName("added_at") val addedAt: String, 52 | val track: Track 53 | ) 54 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/client/ClientProfileApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.client 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyScope 6 | import com.adamratzman.spotify.endpoints.pub.UserApi 7 | import com.adamratzman.spotify.models.SpotifyUserInformation 8 | import com.adamratzman.spotify.models.serialization.toObject 9 | 10 | /** 11 | * Endpoints for retrieving information about a user’s profile. 12 | * 13 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/users-profile/)** 14 | */ 15 | public class ClientProfileApi(api: GenericSpotifyApi) : UserApi(api) { 16 | /** 17 | * Get detailed profile information about the current user (including the current user’s username). 18 | * 19 | * The access token must have been issued on behalf of the current user. 20 | * Reading the user’s email address requires the [SpotifyScope.UserReadEmail] scope; 21 | * reading country and product subscription level requires the [SpotifyScope.UserReadPrivate] scope. 22 | * 23 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/users-profile/get-current-users-profile/)** 24 | * 25 | * @return Never-null [SpotifyUserInformation] object with possibly-null country, email, subscription and birthday fields 26 | */ 27 | public suspend fun getClientProfile(): SpotifyUserInformation = 28 | get(endpointBuilder("/me").toString()).toObject(SpotifyUserInformation.serializer(), api, json) 29 | } 30 | -------------------------------------------------------------------------------- /src/androidMain/kotlin/com/adamratzman/spotify/utils/PlatformUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import android.app.Activity 5 | import android.content.Context 6 | import android.util.Log 7 | import android.widget.Toast 8 | import kotlinx.coroutines.runBlocking 9 | import java.net.URLEncoder 10 | 11 | internal actual fun String.encodeUrl() = URLEncoder.encode(this, "UTF-8")!! 12 | 13 | /** 14 | * Actual platform that this program is run on. 15 | */ 16 | public actual val currentApiPlatform: Platform = Platform.Android 17 | 18 | public actual typealias ConcurrentHashMap = java.util.concurrent.ConcurrentHashMap 19 | 20 | public actual fun ConcurrentHashMap.asList(): List> = toList() 21 | 22 | // safeLet retrieved from: https://stackoverflow.com/a/35522422/6422820 23 | private fun safeLet(p1: T1?, p2: T2?, p3: T3?, block: (T1, T2, T3) -> R?): R? = 24 | if (p1 != null && p2 != null && p3 != null) block(p1, p2, p3) else null 25 | 26 | internal fun toast(context: Context?, message: String?, duration: Int = Toast.LENGTH_SHORT) { 27 | safeLet(context, message, duration) { safeContext, safeMessage, safeDuration -> 28 | (safeContext as? Activity)?.runOnUiThread { 29 | Toast.makeText(safeContext, safeMessage, safeDuration).show() 30 | } 31 | } 32 | } 33 | 34 | internal fun logToConsole(message: String) { 35 | Log.i("spotify-web-api-kotlin", message) 36 | } 37 | 38 | public actual fun runBlockingOnJvmAndNative(block: suspend () -> T): T { 39 | return runBlocking { block() } 40 | } 41 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/utilities/RestTests.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.utilities 5 | 6 | import com.adamratzman.spotify.GenericSpotifyApi 7 | import com.adamratzman.spotify.SpotifyException.TimeoutException 8 | import com.adamratzman.spotify.SpotifyUserAuthorization 9 | import com.adamratzman.spotify.annotations.SpotifyExperimentalHttpApi 10 | import com.adamratzman.spotify.buildSpotifyApi 11 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 12 | import com.adamratzman.spotify.spotifyAppApi 13 | import kotlinx.coroutines.ExperimentalCoroutinesApi 14 | import kotlinx.coroutines.test.TestResult 15 | import kotlin.test.Test 16 | import kotlin.test.assertFailsWith 17 | import kotlin.time.ExperimentalTime 18 | 19 | @ExperimentalTime 20 | @SpotifyExperimentalHttpApi 21 | class RestTests { 22 | var api: GenericSpotifyApi? = null 23 | 24 | fun testPrereq() = api != null 25 | 26 | @Test 27 | fun testRequestTimeoutFailure(): TestResult = runTestOnDefaultDispatcher { 28 | buildSpotifyApi(this::class.simpleName!!, ::testRequestTimeoutFailure.name)?.let { api = it } 29 | 30 | val testApi = spotifyAppApi(null, null, SpotifyUserAuthorization(token = api!!.token)).build() 31 | val prevTimeout = testApi.spotifyApiOptions.requestTimeoutMillis 32 | 33 | testApi.spotifyApiOptions.requestTimeoutMillis = 1 34 | assertFailsWith { 35 | testApi.search.searchTrack("fail") 36 | } 37 | 38 | testApi.spotifyApiOptions.requestTimeoutMillis = prevTimeout 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/AbstractTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify 5 | 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | 8 | abstract class AbstractTest { 9 | lateinit var api: T 10 | var apiInitialized: Boolean = false 11 | val testClassQualifiedName = this::class.simpleName!! 12 | 13 | suspend inline fun buildApi(testName: String) { 14 | if (apiInitialized) return 15 | var requestNumber = 0 // local to the specific test. used to fake request responses 16 | 17 | val api = buildSpotifyApi(testClassQualifiedName, testName) 18 | if (api != null && api is Z) { 19 | api.spotifyApiOptions.retryOnInternalServerErrorTimes = 10 20 | api.spotifyApiOptions.httpResponseSubscriber = { request, response -> 21 | getResponseCacher()?.cacheResponse( 22 | testClassQualifiedName, 23 | testName, 24 | requestNumber, 25 | request, 26 | response 27 | ) 28 | requestNumber++ 29 | } 30 | 31 | this.api = api 32 | apiInitialized = true 33 | } 34 | } 35 | 36 | suspend fun isApiInitialized(): Boolean { 37 | return if (apiInitialized) { 38 | true 39 | } else { 40 | println("Api is not initialized or does not match the expected type. buildSpotifyApi returns ${buildSpotifyApi(testClassQualifiedName, "n/a")}") 41 | false 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/priv/ClientEpisodeApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.priv 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.SpotifyClientApi 8 | import com.adamratzman.spotify.SpotifyException.BadRequestException 9 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.test.TestResult 12 | import kotlin.test.Test 13 | import kotlin.test.assertEquals 14 | import kotlin.test.assertFailsWith 15 | import kotlin.test.assertNotNull 16 | import kotlin.test.assertNull 17 | 18 | class ClientEpisodeApiTest : AbstractTest() { 19 | @Test 20 | fun testGetEpisode(): TestResult = runTestOnDefaultDispatcher { 21 | buildApi(::testGetEpisode.name) 22 | if (!isApiInitialized()) return@runTestOnDefaultDispatcher 23 | 24 | assertNull(api.episodes.getEpisode("nonexistant episode")) 25 | assertNotNull(api.episodes.getEpisode("3lMZTE81Pbrp0U12WZe27l")) 26 | } 27 | 28 | @Test 29 | fun testGetEpisodes(): TestResult = runTestOnDefaultDispatcher { 30 | buildApi(::testGetEpisodes.name) 31 | if (!isApiInitialized()) return@runTestOnDefaultDispatcher 32 | 33 | assertFailsWith { api.episodes.getEpisodes("hi", "dad") } 34 | assertFailsWith { api.episodes.getEpisodes("1cfOhXP4GQCd5ZFHoSF8gg", "j")[1] } 35 | 36 | assertEquals( 37 | listOf("The Great Inflation (Classic)"), 38 | api.episodes.getEpisodes("3lMZTE81Pbrp0U12WZe27l").map { it?.name } 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | We use the multiplatform kotlin.test framework to run tests. 4 | 5 | You must create a Spotify application [here](https://developer.spotify.com/dashboard/applications) to get credentials. 6 | 7 | To run **only** public endpoint tests, you only need `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` as environment variables. 8 | 9 | To additionally run **all** private (client) endpoint tests, you need a valid Spotify application, redirect uri, and token string. 10 | The additional environment variables you will need to add are `SPOTIFY_REDIRECT_URI` and `SPOTIFY_TOKEN_STRING`. 11 | 12 | To specifically run player tests, you must include the `SPOTIFY_ENABLE_PLAYER_TESTS`=true environment variable. 13 | 14 | Some tests may fail if you do not allow access to all required scopes. To mitigate this, you can individually grant 15 | each scope or use the following code snippet to print out the Spotify token string (given a generated authorization code). 16 | However, you can painlessly generate a valid token by using this site: https://adamratzman.com/projects/spotify/generate-token 17 | 18 | To run tests, run `gradle jvmTest`, `gradle macosX64Test`, `gradle testDebugUnitTest`, or any other target. 19 | 20 | To output all http requests to the console, set the `SPOTIFY_LOG_HTTP`=true environment variable. 21 | 22 | To build the maven artifact locally, you will need to follow these steps: 23 | - Create `gradle.properties` if it doesn't exist already. 24 | - Follow [this guide](https://gist.github.com/phit/bd3c6d156a2fa5f3b1bc15fa94b3256c). Instead of `.gpg` extension, use `.kbx` for your secring. 25 | - Run `gradle publishToMavenLocal` 26 | 27 | You can use this artifact to test locally by adding the `mavenLocal()` repository in any local gradle project. 28 | 29 | To build docs, run `gradle dokka`. They will be located under the docs directory in the repostiory root, and 30 | are ignored. This is how we generate release docs. -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/EpisodeApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.SpotifyException.BadRequestException 9 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 10 | import com.adamratzman.spotify.utils.Market 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.test.TestResult 13 | import kotlin.test.* 14 | 15 | class EpisodeApiTest : AbstractTest() { 16 | private val market = Market.US 17 | 18 | @Test 19 | fun testGetEpisode(): TestResult = runTestOnDefaultDispatcher { 20 | buildApi(::testGetEpisode.name) 21 | 22 | assertNull(api.episodes.getEpisode("nonexistant episode", market = market)) 23 | assertEquals( 24 | "The Great Inflation (Classic)", 25 | api.episodes.getEpisode("3lMZTE81Pbrp0U12WZe27l", market = market)?.name 26 | ) 27 | } 28 | 29 | //@Test 30 | //todo re-enable. Flaky test disabled due to infrequent spotify 500s 31 | fun testGetEpisodes(): TestResult = runTestOnDefaultDispatcher { 32 | buildApi(::testGetEpisodes.name) 33 | 34 | assertEquals(listOf(null, null), api.episodes.getEpisodes("hi", "dad", market = market)) 35 | 36 | val firstResultNotNullSecondNull = api.episodes.getEpisodes("1cfOhXP4GQCd5ZFHoSF8gg", "j", market = market).map { it?.name } 37 | assertTrue(firstResultNotNullSecondNull[0] != null) 38 | assertTrue(firstResultNotNullSecondNull[1] == null) 39 | 40 | assertEquals( 41 | listOf("The Great Inflation (Classic)"), 42 | api.episodes.getEpisodes("3lMZTE81Pbrp0U12WZe27l", market = market).map { it?.name } 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/PublicFollowingApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.SpotifyException 9 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.test.TestResult 12 | import kotlin.test.Ignore 13 | import kotlin.test.Test 14 | import kotlin.test.assertEquals 15 | import kotlin.test.assertFailsWith 16 | 17 | class PublicFollowingApiTest : AbstractTest() { 18 | @Ignore // Spotify is currently failing areFollowingPlaylist requests for non-logged in user, contrary to docs 19 | @Test 20 | fun testUsersFollowingPlaylist(): TestResult = runTestOnDefaultDispatcher { 21 | buildApi(::testUsersFollowingPlaylist.name) 22 | 23 | assertFailsWith { 24 | api.following.areFollowingPlaylist( 25 | "37i9dQZF1DXcBWIGoYBM5M", 26 | "udontexist89" 27 | )[0] 28 | } 29 | assertFailsWith { 30 | api.following.areFollowingPlaylist("37i9dQZF1DXcBWIGoYBM5M") 31 | } 32 | assertFailsWith { 33 | api.following.areFollowingPlaylist("asdkfjajksdfjkasdf", "adamratzman1") 34 | } 35 | assertEquals( 36 | listOf(true, false), 37 | api.following.areFollowingPlaylist("37i9dQZF1DXcBWIGoYBM5M", "adamratzman1", "adamratzman") 38 | ) 39 | assertFailsWith { 40 | api.following.areFollowingPlaylist("37i9dQZF1DXcBWIGoYBM5M", "udontexist89", "adamratzman1") 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/utils/ExternalUrls.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.utils 3 | 4 | import com.adamratzman.spotify.models.ExternalUrl 5 | import kotlinx.serialization.Serializable 6 | 7 | /** 8 | * Represents the external urls associated with this Spotify asset. 9 | * 10 | * @param spotify The associated Spotify link for this asset, if one exists. 11 | * @param otherExternalUrls Other external urls, not including [spotify] 12 | */ 13 | @Serializable 14 | public data class ExternalUrls( 15 | val spotify: String? = null, 16 | val otherExternalUrls: List, 17 | private val allExternalUrls: List 18 | ) : List { 19 | override val size: Int = allExternalUrls.size 20 | override fun contains(element: ExternalUrl): Boolean = allExternalUrls.contains(element) 21 | override fun containsAll(elements: Collection): Boolean = allExternalUrls.containsAll(elements) 22 | override fun get(index: Int): ExternalUrl = allExternalUrls.get(index) 23 | override fun indexOf(element: ExternalUrl): Int = allExternalUrls.indexOf(element) 24 | override fun isEmpty(): Boolean = allExternalUrls.isEmpty() 25 | override fun iterator(): Iterator = allExternalUrls.iterator() 26 | override fun lastIndexOf(element: ExternalUrl): Int = allExternalUrls.lastIndexOf(element) 27 | override fun listIterator(): ListIterator = allExternalUrls.listIterator() 28 | override fun listIterator(index: Int): ListIterator = allExternalUrls.listIterator(index) 29 | override fun subList(fromIndex: Int, toIndex: Int): List = allExternalUrls.subList(fromIndex, toIndex) 30 | } 31 | 32 | public fun getExternalUrls(externalUrlsString: Map): ExternalUrls { 33 | val externalUrls = externalUrlsString.map { ExternalUrl(it.key, it.value) } 34 | 35 | return ExternalUrls( 36 | externalUrlsString["spotify"], 37 | externalUrls.filter { it.name != "spotify" }, 38 | externalUrls 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/ShowApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.SpotifyException.BadRequestException 9 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 10 | import com.adamratzman.spotify.utils.Market 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.test.TestResult 13 | import kotlin.test.* 14 | 15 | class ShowApiTest : AbstractTest() { 16 | private val market = Market.US 17 | 18 | @Test 19 | fun testGetShow(): TestResult = runTestOnDefaultDispatcher { 20 | buildApi(::testGetShow.name) 21 | 22 | assertNull(api.shows.getShow("invalid-show", market = market)) 23 | assertEquals( 24 | "Freakonomics Radio", 25 | api.shows.getShow("spotify:show:6z4NLXyHPga1UmSJsPK7G1", market = market)?.name 26 | ) 27 | } 28 | 29 | @Test 30 | fun testGetShows(): TestResult = runTestOnDefaultDispatcher { 31 | buildApi(::testGetShows.name) 32 | 33 | assertContentEquals(listOf(null, null), api.shows.getShows("hi", "dad", market = market)) 34 | assertContentEquals( 35 | listOf(null, null), 36 | api.shows.getShows("78sdfjsdjfsjdf", "j", market = market).map { it?.id } 37 | ) 38 | assertContentEquals( 39 | listOf("Freakonomics Radio"), 40 | api.shows.getShows("6z4NLXyHPga1UmSJsPK7G1", market = market).map { it?.name } 41 | ) 42 | } 43 | 44 | @Test 45 | fun testGetShowEpisodes(): TestResult = runTestOnDefaultDispatcher { 46 | buildApi(::testGetShowEpisodes.name) 47 | 48 | assertFailsWith { api.shows.getShowEpisodes("hi", market = market) } 49 | val show = api.shows.getShow("6z4NLXyHPga1UmSJsPK7G1", market = market)!! 50 | assertEquals( 51 | show.id, 52 | api.shows.getShowEpisodes(show.id, market = market).first()?.toFullEpisode(market)?.show?.id 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/androidMain/kotlin/com/adamratzman/spotify/auth/implicit/ImplicitAuthUtils.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.auth.implicit 3 | 4 | import android.app.Activity 5 | import android.content.Intent 6 | import com.adamratzman.spotify.SpotifyException 7 | import com.adamratzman.spotify.auth.SpotifyDefaultCredentialStore.Companion.activityBackOnImplicitAuth 8 | 9 | // Starting implicit login activity 10 | 11 | /** 12 | * Start Spotify implicit login activity within an existing activity. 13 | */ 14 | public inline fun Activity.startSpotifyImplicitLoginActivity() { 15 | startSpotifyImplicitLoginActivity(T::class.java) 16 | } 17 | 18 | /** 19 | * Start Spotify implicit login activity within an existing activity. 20 | * 21 | * @param spotifyLoginImplementationClass Your implementation of [SpotifyImplicitLoginActivity], defining what to do on Spotify login 22 | */ 23 | public fun Activity.startSpotifyImplicitLoginActivity(spotifyLoginImplementationClass: Class) { 24 | startActivity(Intent(this, spotifyLoginImplementationClass)) 25 | } 26 | 27 | /** 28 | * Basic implicit authentication guard - verifies that the user is logged in to Spotify and uses [SpotifyDefaultImplicitAuthHelper] to 29 | * handle re-authentication and redirection back to the activity. 30 | * 31 | * Note: this should only be used for small applications. 32 | * 33 | * @param spotifyImplicitLoginImplementationClass Your implementation of [SpotifyImplicitLoginActivity], defining what to do on Spotify login 34 | * @param classBackTo The activity to return to if re-authentication is necessary 35 | * @block The code block to execute 36 | */ 37 | public fun Activity.guardValidImplicitSpotifyApi( 38 | spotifyImplicitLoginImplementationClass: Class, 39 | classBackTo: Class? = null, 40 | block: () -> T 41 | ): T? { 42 | return try { 43 | block() 44 | } catch (e: SpotifyException.ReAuthenticationNeededException) { 45 | activityBackOnImplicitAuth = classBackTo 46 | startSpotifyImplicitLoginActivity(spotifyImplicitLoginImplementationClass) 47 | null 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/co.scdn.sdk/SpotifyPlayerJs.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package co.scdn.sdk 3 | 4 | import com.adamratzman.spotify.webplayer.Error 5 | import com.adamratzman.spotify.webplayer.PlaybackState 6 | import com.adamratzman.spotify.webplayer.WebPlaybackInstance 7 | import kotlinx.browser.window 8 | 9 | public fun setOnSpotifyWebPlaybackSDKReady(callback: suspend () -> Unit) { 10 | val dynamicWindow: dynamic = window 11 | dynamicWindow["onSpotifyWebPlaybackSDKReady"] = callback 12 | } 13 | 14 | public enum class SpotifyWebPlaybackEvent(public val spotifyId: String) { 15 | /** 16 | * Emitted when the Web Playback SDK has successfully connected and is ready to stream content in the browser from Spotify. Type [PlaybackPlayerListener] 17 | */ 18 | Ready("ready"), 19 | 20 | /** 21 | * Emitted when the Web Playback SDK is not ready to play content, typically due to no internet connection. Type [PlaybackPlayerListener] 22 | */ 23 | NotReady("not_ready"), 24 | 25 | /** 26 | * Emitted when the state of the local playback has changed. It may be also executed in random intervals. Type [PlaybackStateListener] 27 | */ 28 | PlayerStateChanged("player_state_changed"), 29 | 30 | /** 31 | * Emitted when the Spotify.Player fails to instantiate a player capable of playing content in the current environment. 32 | * Most likely due to the browser not supporting EME protection. Type [ErrorListener] 33 | */ 34 | InitializationError("initialization_error"), 35 | 36 | /** 37 | * Emitted when the Spotify.Player fails to instantiate a valid Spotify connection from the access token provided to getOAuthToken. Type [ErrorListener] 38 | */ 39 | AuthenticationError("authentication_error"), 40 | 41 | /** 42 | * Emitted when the user authenticated does not have a valid Spotify Premium subscription. Type [ErrorListener] 43 | */ 44 | AccountError("account_error"), 45 | 46 | /** 47 | * Emitted when loading and/or playing back a track failed. Type [ErrorListener] 48 | */ 49 | PlaybackError("playback_error") 50 | } 51 | 52 | public typealias ErrorListener = (err: Error) -> Unit 53 | public typealias PlaybackPlayerListener = (inst: WebPlaybackInstance) -> Unit 54 | public typealias PlaybackStateListener = (s: PlaybackState) -> Unit 55 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/Authentication.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import com.adamratzman.spotify.SpotifyApi 5 | import com.adamratzman.spotify.SpotifyScope 6 | import com.adamratzman.spotify.utils.getCurrentTimeMs 7 | import kotlinx.serialization.SerialName 8 | import kotlinx.serialization.Serializable 9 | import kotlinx.serialization.Transient 10 | 11 | /** 12 | * Represents a Spotify Token, retrieved through instantiating a [SpotifyApi] 13 | * 14 | * @param accessToken An access token that can be provided in subsequent calls, 15 | * for example to Spotify Web API services. 16 | * @param tokenType How the access token may be used: always Bearer”. 17 | * @param expiresIn The time period (in seconds) for which the access token is valid. 18 | * @param refreshToken A token that can be sent to the Spotify Accounts service in place of an authorization code, 19 | * null if the token was created using a method that does not support token refresh 20 | * 21 | * @property scopes A list of scopes granted access for this [accessToken]. An 22 | * empty list means that the token can only be used to access public information. 23 | * @property expiresAt The time, in milliseconds, at which this Token expires 24 | */ 25 | @Serializable 26 | public data class Token( 27 | @SerialName("access_token") var accessToken: String, 28 | @SerialName("token_type") val tokenType: String, 29 | @SerialName("expires_in") var expiresIn: Int, 30 | @SerialName("refresh_token") var refreshToken: String? = null, 31 | @SerialName("scope") internal var scopeString: String? = null 32 | ) { 33 | val expiresAt: Long get() = getCurrentTimeMs() + expiresIn * 1000 34 | 35 | val scopes: List? get() = scopeString?.let { str -> 36 | str.split(" ").mapNotNull { scope -> SpotifyScope.entries.find { it.uri.equals(scope, true) } } 37 | } 38 | 39 | public fun shouldRefresh(): Boolean = getCurrentTimeMs() > expiresAt 40 | 41 | public companion object { 42 | public fun from(accessToken: String?, refreshToken: String?, scopes: List, expiresIn: Int = 1): Token = 43 | Token(accessToken ?: "", "Bearer", expiresIn, refreshToken, scopes.joinToString(" ") { it.uri }) 44 | } 45 | } 46 | 47 | @Serializable 48 | public data class TokenValidityResponse( 49 | val isValid: Boolean, 50 | @Transient val exception: Exception? = null 51 | ) 52 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/Browse.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | /** 8 | * Spotify music category 9 | * 10 | * @param href A link to the Web API endpoint returning full details of the category. 11 | * @param icons The category icon, in various sizes. 12 | * @param id The Spotify category ID of the category. 13 | * @param name The name of the category. 14 | */ 15 | @Serializable 16 | public data class SpotifyCategory( 17 | override val href: String, 18 | override val id: String, 19 | 20 | val icons: List, 21 | val name: String 22 | ) : Identifiable() 23 | 24 | /** 25 | * Seed from which the recommendation was constructed 26 | * 27 | * @param initialPoolSize The number of recommended tracks available for this seed. 28 | * @param afterFilteringSize The number of tracks available after min_* and max_* filters have been applied. 29 | * @param afterRelinkingSize The number of tracks available after relinking for regional availability. 30 | * @param href A link to the full track or artist data for this seed. For tracks this will be a link to a Track 31 | * Object. For artists a link to an Artist Object. For genre seeds, this value will be null. 32 | * @param id The id used to select this seed. This will be the same as the string used in the 33 | * seed_artists , seed_tracks or seed_genres parameter. 34 | * @param type The entity type of this seed. One of artist , track or genre. 35 | */ 36 | @Serializable 37 | public data class RecommendationSeed( 38 | @SerialName("href") override val href: String? = null, 39 | @SerialName("id") override val id: String, 40 | 41 | val initialPoolSize: Int, 42 | val afterFilteringSize: Int, 43 | val afterRelinkingSize: Int? = null, 44 | val type: String 45 | ) : Identifiable() 46 | 47 | /** 48 | * @param seeds An array of recommendation seed objects. 49 | * @param tracks An array of track object (simplified) ordered according to the parameters supplied. 50 | */ 51 | @Serializable 52 | public data class RecommendationResponse(val seeds: List, val tracks: List) 53 | 54 | /** 55 | * Spotify featured playlists (on the Browse tab) 56 | * 57 | * @param message the featured message in "Overview" 58 | * @param playlists [PagingObject] of returned items 59 | */ 60 | @Serializable 61 | public data class FeaturedPlaylists(val message: String, val playlists: PagingObject) 62 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/PublicTracksApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.SpotifyException 9 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 10 | import com.adamratzman.spotify.utils.Market 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.test.TestResult 13 | import kotlin.test.Test 14 | import kotlin.test.assertEquals 15 | import kotlin.test.assertFailsWith 16 | import kotlin.test.assertNull 17 | import kotlin.test.assertTrue 18 | 19 | class PublicTracksApiTest : AbstractTest() { 20 | @Test 21 | fun testGetTrack(): TestResult = runTestOnDefaultDispatcher { 22 | buildApi(::testGetTrack.name) 23 | 24 | assertEquals("Bénabar", api.tracks.getTrack("5OT3k9lPxI2jkaryRK3Aop")!!.artists[0].name) 25 | assertNull(api.tracks.getTrack("nonexistant track")) 26 | } 27 | 28 | @Test 29 | fun testGetTracks(): TestResult = runTestOnDefaultDispatcher { 30 | buildApi(::testGetTracks.name) 31 | 32 | assertEquals(listOf(null, null), api.tracks.getTracks("hi", "dad", market = Market.US)) 33 | assertEquals( 34 | listOf("Alors souris", null), 35 | api.tracks.getTracks("0o4jSZBxOQUiDKzMJSqR4x", "j").map { it?.name } 36 | ) 37 | } 38 | 39 | @Test 40 | fun testAudioAnalysis(): TestResult = runTestOnDefaultDispatcher { 41 | buildApi(::testAudioAnalysis.name) 42 | 43 | assertFailsWith { api.tracks.getAudioAnalysis("bad track") } 44 | assertEquals("165.61333", api.tracks.getAudioAnalysis("0o4jSZBxOQUiDKzMJSqR4x").track.duration.toString()) 45 | } 46 | 47 | @Test 48 | fun testAudioFeatures(): TestResult = runTestOnDefaultDispatcher { 49 | buildApi(::testAudioFeatures.name) 50 | 51 | assertFailsWith { api.tracks.getAudioFeatures("bad track") } 52 | assertEquals("0.0592", api.tracks.getAudioFeatures("6AH3IbS61PiabZYKVBqKAk").acousticness.toString()) 53 | assertEquals( 54 | listOf(null, "0.0592"), 55 | api.tracks.getAudioFeatures("hkiuhi", "6AH3IbS61PiabZYKVBqKAk").map { it?.acousticness?.toString() } 56 | ) 57 | assertTrue( 58 | api.tracks.getAudioFeatures("bad track", "0o4jSZBxOQUiDKzMJSqR4x").let { 59 | it[0] == null && it[1] != null 60 | } 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/Playable.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.Serializable 6 | import kotlinx.serialization.json.JsonContentPolymorphicSerializer 7 | import kotlinx.serialization.json.JsonElement 8 | import kotlinx.serialization.json.JsonObject 9 | import kotlinx.serialization.json.contentOrNull 10 | import kotlinx.serialization.json.jsonPrimitive 11 | 12 | /** 13 | * A local track, episode, or track. Serialized with [PlayableSerializer] 14 | * 15 | * @property href A link to the Web API endpoint providing full details of the playable. 16 | * @property id The Spotify ID for the playable. 17 | * @property uri The URI associated with the object. 18 | * @property type The type of the playable. 19 | * 20 | */ 21 | @Serializable(with = PlayableSerializer::class) 22 | public interface Playable { 23 | public val href: String? 24 | public val id: String? 25 | public val uri: PlayableUri 26 | public val type: String 27 | 28 | /** 29 | * This Playable as a local track, or else null if it is an episode or track. 30 | * 31 | */ 32 | public val asLocalTrack: LocalTrack? get() = this as? LocalTrack 33 | 34 | /** 35 | * This Playable as an episode (podcast), or else null if it is a local track or track. 36 | * 37 | */ 38 | public val asPodcastEpisodeTrack: PodcastEpisodeTrack? get() = this as? PodcastEpisodeTrack 39 | 40 | /** 41 | * This Playable as a track, or else null if it is a local track or episode (podcast). 42 | * 43 | */ 44 | public val asTrack: Track? get() = this as? Track 45 | } 46 | 47 | public object PlayableSerializer : 48 | KSerializer by object : JsonContentPolymorphicSerializer(Playable::class) { 49 | override fun selectDeserializer(element: JsonElement): KSerializer { 50 | return when ( 51 | val uri: PlayableUri? = 52 | (element as? JsonObject)?.get("uri")?.jsonPrimitive?.contentOrNull?.let { PlayableUri(it) } 53 | ) { 54 | is LocalTrackUri -> LocalTrack.serializer() 55 | is EpisodeUri -> { 56 | if ((element as? JsonObject)?.get("show") != null) { 57 | Episode.serializer() 58 | } else { 59 | PodcastEpisodeTrack.serializer() 60 | } 61 | } 62 | is SpotifyTrackUri -> Track.serializer() 63 | null -> throw IllegalStateException("Couldn't find a serializer for uri $uri") 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/PublicAlbumsApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.SpotifyException 9 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 10 | import com.adamratzman.spotify.utils.Market 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.test.TestResult 13 | import kotlin.test.Test 14 | import kotlin.test.assertEquals 15 | import kotlin.test.assertFailsWith 16 | import kotlin.test.assertFalse 17 | import kotlin.test.assertNotNull 18 | import kotlin.test.assertNull 19 | import kotlin.test.assertTrue 20 | 21 | class PublicAlbumsApiTest : AbstractTest() { 22 | @Test 23 | fun testGetAlbums(): TestResult = runTestOnDefaultDispatcher { 24 | buildApi(::testGetAlbums.name) 25 | 26 | assertNull(api.albums.getAlbum("asdf", Market.FR)) 27 | assertNull(api.albums.getAlbum("asdf")) 28 | assertNotNull(api.albums.getAlbum("1f1C1CjidKcWQyiIYcMvP2")) 29 | assertNotNull(api.albums.getAlbum("1f1C1CjidKcWQyiIYcMvP2", Market.US)) 30 | 31 | assertFailsWith { api.albums.getAlbums(market = Market.US) } 32 | assertFailsWith { api.albums.getAlbums() } 33 | assertEquals( 34 | listOf(true, false), 35 | api.albums.getAlbums("1f1C1CjidKcWQyiIYcMvP2", "abc", market = Market.US) 36 | .map { it != null } 37 | ) 38 | assertEquals( 39 | listOf(true, false), 40 | api.albums.getAlbums("1f1C1CjidKcWQyiIYcMvP2", "abc").map { it != null } 41 | ) 42 | } 43 | 44 | @Test 45 | fun testGetAlbumsTracks(): TestResult = runTestOnDefaultDispatcher { 46 | buildApi(::testGetAlbumsTracks.name) 47 | 48 | assertFailsWith { api.albums.getAlbumTracks("no") } 49 | 50 | assertTrue(api.albums.getAlbumTracks("29ct57rVIi3MIFyKJYUWrZ", 4, 3, Market.US).items.isNotEmpty()) 51 | assertTrue(api.albums.getAlbumTracks("29ct57rVIi3MIFyKJYUWrZ", 4, 3).items.isNotEmpty()) 52 | assertFalse(api.albums.getAlbumTracks("29ct57rVIi3MIFyKJYUWrZ", 4, 3, Market.US).items[0].isRelinked()) 53 | } 54 | 55 | @Test 56 | fun testConvertSimpleAlbumToAlbum(): TestResult = runTestOnDefaultDispatcher { 57 | buildApi(::testConvertSimpleAlbumToAlbum.name) 58 | 59 | val simpleAlbum = api.tracks.getTrack("53BHUFdQphHiZUUG3nx9zn")!!.album 60 | assertEquals(simpleAlbum.id, simpleAlbum.toFullAlbum()?.id) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/Artists.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import com.adamratzman.spotify.SpotifyRestAction 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | /** 9 | * Simplified Artist object that can be used to retrieve a full [Artist] 10 | * 11 | * @param href A link to the Web API endpoint providing full details of the artist. 12 | * @param id The Spotify ID for the artist. 13 | * @param name The name of the artist 14 | * @param type The object type: "artist" 15 | */ 16 | @Serializable 17 | public data class SimpleArtist( 18 | @SerialName("external_urls") override val externalUrlsString: Map, 19 | override val href: String, 20 | override val id: String, 21 | override val uri: SpotifyUri, 22 | 23 | val name: String? = null, 24 | val type: String 25 | ) : CoreObject() { 26 | /** 27 | * Converts this [SimpleArtist] into a full [Artist] object 28 | */ 29 | public suspend fun toFullArtist(): Artist? = api.artists.getArtist(id) 30 | 31 | /** 32 | * Converts this [SimpleArtist] into a full [Artist] object 33 | */ 34 | public fun toFullArtistRestAction(): SpotifyRestAction = SpotifyRestAction { toFullArtist() } 35 | 36 | override fun getMembersThatNeedApiInstantiation(): List = listOf(this) 37 | } 38 | 39 | /** 40 | * Represents an Artist (distinct from a regular user) on Spotify 41 | * 42 | * @param followers Information about the followers of the artist. 43 | * @param genres A list of the genres the artist is associated with. For example: "Prog Rock" , 44 | * "Post-Grunge". (If not yet classified, the array is empty.) 45 | * @param href A link to the Web API endpoint providing full details of the artist. 46 | * @param id The Spotify ID for the artist. 47 | * @param images Images of the artist in various sizes, widest first. 48 | * @param name The name of the artist 49 | * @param popularity The popularity of the artist. The value will be between 0 and 100, with 100 being the most 50 | * popular. The artist’s popularity is calculated from the popularity of all the artist’s tracks. 51 | * @param type The object type: "artist" 52 | */ 53 | @Serializable 54 | public data class Artist( 55 | @SerialName("external_urls") override val externalUrlsString: Map, 56 | override val href: String, 57 | override val id: String, 58 | override val uri: ArtistUri, 59 | 60 | val followers: Followers, 61 | val genres: List, 62 | val images: List? = null, 63 | val name: String? = null, 64 | val popularity: Double, 65 | val type: String 66 | ) : CoreObject() { 67 | override fun getMembersThatNeedApiInstantiation(): List = listOf(this) 68 | } 69 | 70 | @Serializable 71 | internal data class ArtistList(val artists: List) 72 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/pub/FollowingApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.pub 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyException.BadRequestException 6 | import com.adamratzman.spotify.http.SpotifyEndpoint 7 | import com.adamratzman.spotify.models.PlaylistUri 8 | import com.adamratzman.spotify.models.UserUri 9 | import com.adamratzman.spotify.models.serialization.toList 10 | import com.adamratzman.spotify.utils.encodeUrl 11 | import kotlinx.serialization.builtins.ListSerializer 12 | import kotlinx.serialization.builtins.serializer 13 | 14 | /** 15 | * This endpoint allow you check the playlists that a Spotify user follows. 16 | * 17 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/follow/)** 18 | */ 19 | public open class FollowingApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) { 20 | /** 21 | * Check to see if one or more Spotify users are following a specified playlist. 22 | * 23 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/follow/check-user-following-playlist/)** 24 | * 25 | * @param playlist playlist id or uri 26 | * @param users user ids or uris to check. Maximum **5**. 27 | * 28 | * @return List of Booleans representing whether the user follows the playlist. User IDs **not** found will return false 29 | * 30 | * @throws [BadRequestException] if the playlist is not found OR any user in the list does not exist 31 | */ 32 | public suspend fun areFollowingPlaylist( 33 | playlist: String, 34 | vararg users: String 35 | ): List { 36 | checkBulkRequesting(5, users.size) 37 | 38 | return bulkStatelessRequest(5, users.toList()) { chunk -> 39 | get( 40 | endpointBuilder("/playlists/${PlaylistUri(playlist).id.encodeUrl()}/followers/contains") 41 | .with("ids", chunk.joinToString(",") { UserUri(it).id.encodeUrl() }).toString() 42 | ).toList(ListSerializer(Boolean.serializer()), api, json) 43 | }.flatten() 44 | } 45 | 46 | /** 47 | * Check to see if a specific Spotify user is following the specified playlist. 48 | * 49 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/follow/check-user-following-playlist/)** 50 | * 51 | * @param playlist playlist id or uri 52 | * @param user Spotify user id 53 | * 54 | * @return booleans representing whether the user follows the playlist. User IDs **not** found will return false 55 | * 56 | * @throws [BadRequestException] if the playlist is not found or if the user does not exist 57 | */ 58 | public suspend fun isFollowingPlaylist(playlist: String, user: String): Boolean = areFollowingPlaylist( 59 | playlist, 60 | users = arrayOf(user) 61 | )[0] 62 | } 63 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/client/ClientEpisodeApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.client 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyException.BadRequestException 6 | import com.adamratzman.spotify.SpotifyScope 7 | import com.adamratzman.spotify.endpoints.pub.EpisodeApi 8 | import com.adamratzman.spotify.models.Episode 9 | import com.adamratzman.spotify.models.EpisodeList 10 | import com.adamratzman.spotify.models.EpisodeUri 11 | import com.adamratzman.spotify.models.serialization.toObject 12 | import com.adamratzman.spotify.utils.Market 13 | import com.adamratzman.spotify.utils.catch 14 | import com.adamratzman.spotify.utils.encodeUrl 15 | 16 | /** 17 | * Endpoints for retrieving information about one or more episodes from the Spotify catalog. 18 | * 19 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/episodes/)** 20 | */ 21 | public class ClientEpisodeApi(api: GenericSpotifyApi) : EpisodeApi(api) { 22 | /** 23 | * Get Spotify catalog information for a single episode identified by its unique Spotify ID. The [Market] associated with 24 | * the user account will be used. 25 | * 26 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 27 | * 28 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/episodes/get-an-episode/)** 29 | * 30 | * @param id The Spotify ID for the episode. 31 | * 32 | * @return possibly-null [Episode]. 33 | */ 34 | public suspend fun getEpisode(id: String): Episode? { 35 | return catch { 36 | get( 37 | endpointBuilder("/episodes/${EpisodeUri(id).id.encodeUrl()}").toString() 38 | ).toObject(Episode.serializer(), api, json) 39 | } 40 | } 41 | 42 | /** 43 | * Get Spotify catalog information for multiple episodes based on their Spotify IDs. The [Market] associated with 44 | * the user account will be used. 45 | * 46 | * **Invalid episode ids will result in a [BadRequestException] 47 | * 48 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 49 | * 50 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/episodes/get-several-episodes/)** 51 | * 52 | * @param ids The id or uri for the episodes. Maximum **50**. 53 | * 54 | * @return List of possibly-null [Episode] objects. 55 | * @throws BadRequestException If any invalid show id is provided 56 | */ 57 | public suspend fun getEpisodes(vararg ids: String): List { 58 | requireScopes(SpotifyScope.UserReadPlaybackPosition) 59 | checkBulkRequesting(50, ids.size) 60 | 61 | return bulkStatelessRequest(50, ids.toList()) { chunk -> 62 | get( 63 | endpointBuilder("/episodes") 64 | .with("ids", chunk.joinToString(",") { EpisodeUri(it).id.encodeUrl() }) 65 | .toString() 66 | ).toObject(EpisodeList.serializer(), api, json).episodes 67 | }.flatten() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/Common.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify 3 | 4 | import com.adamratzman.spotify.http.HttpRequest 5 | import com.adamratzman.spotify.http.HttpResponse 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.test.TestResult 10 | import kotlinx.coroutines.test.runTest 11 | import kotlinx.coroutines.withContext 12 | import kotlinx.serialization.Serializable 13 | import kotlin.test.assertTrue 14 | import kotlin.time.Duration.Companion.seconds 15 | 16 | expect fun areLivePkceTestsEnabled(): Boolean 17 | expect fun arePlayerTestsEnabled(): Boolean 18 | expect fun getTestClientId(): String? 19 | expect fun getTestClientSecret(): String? 20 | expect fun getTestRedirectUri(): String? 21 | expect fun getTestTokenString(): String? 22 | expect fun isHttpLoggingEnabled(): Boolean 23 | expect suspend fun buildSpotifyApi(testClassQualifiedName: String, testName: String): GenericSpotifyApi? 24 | expect fun getResponseCacher(): ResponseCacher? 25 | 26 | interface ResponseCacher { 27 | val cachedResponsesDirectoryPath: String 28 | fun cacheResponse(className: String, testName: String, responseNumber: Int, request: HttpRequest, response: HttpResponse) 29 | } 30 | 31 | suspend inline fun assertFailsWithSuspend(crossinline block: suspend () -> Unit) { 32 | val noExceptionMessage = "Expected ${T::class.simpleName} exception to be thrown, but no exception was thrown." 33 | try { 34 | block() 35 | throw AssertionError(noExceptionMessage) 36 | } catch (exception: Throwable) { 37 | if (exception.message == noExceptionMessage) throw exception 38 | assertTrue( 39 | exception is T, 40 | "Expected ${T::class.simpleName} exception to be thrown, but exception ${exception::class.simpleName} (${exception.message}) was thrown." 41 | ) 42 | } 43 | } 44 | 45 | fun runTestOnDefaultDispatcher(block: suspend CoroutineScope.() -> T): TestResult = runTestOnDefaultDispatcher(block, shouldRetry = true) 46 | 47 | fun runTestOnDefaultDispatcher(block: suspend CoroutineScope.() -> T, shouldRetry: Boolean): TestResult = runTest(timeout = 60.seconds) { 48 | withContext(Dispatchers.Default) { 49 | try { 50 | block() 51 | } catch (e: SpotifyException.BadRequestException) { 52 | // we shouldn't fail just because we received a 5xx from spotify 53 | if (e.statusCode in 500..599) { 54 | println("Received 5xx for block.") 55 | } 56 | 57 | if (shouldRetry) runTestOnDefaultDispatcher(block, shouldRetry = false) 58 | else throw e; 59 | } catch (e: Exception) { 60 | if (shouldRetry) runTestOnDefaultDispatcher(block, shouldRetry = false) 61 | else throw e; 62 | } 63 | } 64 | } 65 | 66 | @Serializable 67 | data class CachedResponse(val request: Request, val response: Response) 68 | 69 | @Serializable 70 | data class Request(val url: String, val method: String, val body: String? = null) 71 | 72 | @Serializable 73 | data class Response(val responseCode: Int, val headers: Map, val body: String) 74 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/PublicArtistsApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.SpotifyException 9 | import com.adamratzman.spotify.endpoints.pub.ArtistApi 10 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 11 | import com.adamratzman.spotify.utils.Market 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi 13 | import kotlinx.coroutines.test.TestResult 14 | import kotlin.test.Test 15 | import kotlin.test.assertEquals 16 | import kotlin.test.assertFailsWith 17 | import kotlin.test.assertNotNull 18 | import kotlin.test.assertNull 19 | import kotlin.test.assertTrue 20 | 21 | class PublicArtistsApiTest : AbstractTest() { 22 | @Test 23 | fun testGetArtists(): TestResult = runTestOnDefaultDispatcher { 24 | buildApi(::testGetArtists.name) 25 | 26 | assertNull(api.artists.getArtist("adkjlasdf")) 27 | assertNotNull(api.artists.getArtist("66CXWjxzNUsdJxJ2JdwvnR")) 28 | assertFailsWith { api.artists.getArtists() } 29 | assertEquals( 30 | listOf(true, true), 31 | api.artists.getArtists("66CXWjxzNUsdJxJ2JdwvnR", "7wjeXCtRND2ZdKfMJFu6JC") 32 | .map { it != null } 33 | ) 34 | 35 | try { 36 | assertEquals( 37 | listOf(false, true), 38 | api.artists.getArtists("dskjafjkajksdf", "0szWPxzzE8DVEfXFRCLBUb") 39 | .map { it != null } 40 | ) 41 | } catch (ignored: Exception) { 42 | // can throw BadRequestException on client api 43 | } 44 | } 45 | 46 | @Test 47 | fun testGetArtistAlbums(): TestResult = runTestOnDefaultDispatcher { 48 | buildApi(::testGetArtistAlbums.name) 49 | 50 | assertFailsWith { api.artists.getArtistAlbums("asfasdf") } 51 | assertTrue( 52 | api.artists.getArtistAlbums( 53 | "7wjeXCtRND2ZdKfMJFu6JC", 54 | 10, 55 | include = arrayOf(ArtistApi.AlbumInclusionStrategy.Album) 56 | ) 57 | .items.asSequence().map { it.name }.contains("Louane") 58 | ) 59 | } 60 | 61 | @Test 62 | fun testGetRelatedArtists(): TestResult = runTestOnDefaultDispatcher { 63 | buildApi(::testGetRelatedArtists.name) 64 | 65 | assertFailsWith { api.artists.getRelatedArtists("") } 66 | assertFailsWith { api.artists.getRelatedArtists("no") } 67 | assertTrue(api.artists.getRelatedArtists("0X2BH1fck6amBIoJhDVmmJ").isNotEmpty()) 68 | } 69 | 70 | @Test 71 | fun testGetArtistTopTracksByMarket(): TestResult = runTestOnDefaultDispatcher { 72 | buildApi(::testGetArtistTopTracksByMarket.name) 73 | 74 | assertFailsWith { api.artists.getArtistTopTracks("no") } 75 | assertTrue(api.artists.getArtistTopTracks("4ZGK4hkNX6pilPpyy4YJJW").isNotEmpty()) 76 | assertTrue(api.artists.getArtistTopTracks("4ZGK4hkNX6pilPpyy4YJJW", Market.FR).isNotEmpty()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/com/adamratzman/spotify/PkceTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify 5 | 6 | import com.adamratzman.spotify.SpotifyException.AuthenticationException 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.runBlocking 9 | import spark.Spark.exception 10 | import spark.Spark.get 11 | import spark.Spark.port 12 | import kotlin.random.Random 13 | import kotlin.test.Test 14 | import kotlin.test.assertFailsWith 15 | 16 | class PkceTest { 17 | 18 | @Test 19 | fun testPkce() = runTestOnDefaultDispatcher { 20 | val clientId = getTestClientId() 21 | 22 | if (areLivePkceTestsEnabled() && clientId != null) { 23 | val serverRedirectUri = "http://localhost:1337" 24 | 25 | val pkceCodeVerifier = (1..100).joinToString("") { "1" } 26 | val state = Random.nextLong().toString() 27 | 28 | println( 29 | getSpotifyPkceAuthorizationUrl( 30 | *SpotifyScope.entries.toTypedArray(), 31 | clientId = clientId, 32 | redirectUri = serverRedirectUri, 33 | codeChallenge = getSpotifyPkceCodeChallenge(pkceCodeVerifier), 34 | state = state 35 | ) 36 | ) 37 | 38 | var stop = false 39 | 40 | port(1337) 41 | 42 | exception(Exception::class.java) { exception, _, _ -> exception.printStackTrace() } 43 | 44 | get("/") { request, _ -> 45 | runBlocking { 46 | val code = request.queryParams("code") 47 | val actualState = request.queryParams("state") 48 | if (code != null && actualState == state) { 49 | val api = spotifyClientPkceApi( 50 | clientId, 51 | serverRedirectUri, 52 | SpotifyUserAuthorization( 53 | authorizationCode = code, 54 | pkceCodeVerifier = pkceCodeVerifier 55 | ) 56 | ) { 57 | onTokenRefresh = { println("refreshed token") } 58 | testTokenValidity = true 59 | }.build() 60 | val token = api.token.copy(expiresIn = -1) 61 | api.refreshToken() 62 | // test that using same token will fail with auth exception 63 | 64 | assertFailsWith { 65 | spotifyClientPkceApi( 66 | clientId, 67 | serverRedirectUri, 68 | SpotifyUserAuthorization( 69 | token = token, 70 | pkceCodeVerifier = pkceCodeVerifier 71 | ) 72 | ).build().library.getSavedTracks() 73 | } 74 | 75 | val username = api.users.getClientProfile().displayName 76 | 77 | stop = true 78 | "Successfully authenticated $username with PKCE and refreshed the token." 79 | } else { 80 | "err." 81 | } 82 | } 83 | } 84 | 85 | println("Waiting...") 86 | 87 | while (!stop) { 88 | Thread.sleep(2000) 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/SpotifyException.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify 3 | 4 | import com.adamratzman.spotify.models.AuthenticationError 5 | import com.adamratzman.spotify.models.ErrorObject 6 | import io.ktor.client.plugins.ResponseException 7 | 8 | public sealed class SpotifyException(message: String, cause: Throwable? = null) : Exception(message, cause) { 9 | public abstract class UnNullableException(message: String) : SpotifyException(message) 10 | 11 | /** 12 | * Thrown when a request fails. 13 | * 14 | * @param statusCode The status code of the request, if this exception is thrown after the completion of an HTTP request. 15 | * @param reason The reason for the failure, as a readable message. 16 | */ 17 | public open class BadRequestException( 18 | message: String, 19 | public val statusCode: Int? = null, 20 | public val reason: String? = null, 21 | cause: Throwable? = null 22 | ) : 23 | SpotifyException(message, cause) { 24 | public constructor(message: String, cause: Throwable? = null) : this(message, null, null, cause) 25 | public constructor(error: ErrorObject?, cause: Throwable? = null) : this( 26 | "Received Status Code ${error?.status}. Error cause: ${error?.message}" + ( 27 | error?.reason?.let { ". Reason: ${error.reason}" } 28 | ?: "" 29 | ), 30 | error?.status, 31 | error?.reason, 32 | cause 33 | ) 34 | 35 | public constructor(authenticationError: AuthenticationError) : 36 | this( 37 | "Authentication error: ${authenticationError.error}. Description: ${authenticationError.description}", 38 | 401 39 | ) 40 | 41 | public constructor(responseException: ResponseException) : 42 | this( 43 | responseException.message ?: "Bad Request", 44 | responseException.response.status.value, 45 | null, 46 | responseException 47 | ) 48 | } 49 | 50 | /** 51 | * Exception signifying that JSON (de)serialization failed. This is likely a library error rather than user error. 52 | */ 53 | public class ParseException(message: String, cause: Throwable? = null) : SpotifyException(message, cause) 54 | 55 | /** 56 | * Exception signifying that authentication (via token or code) was unsuccessful, likely due to an invalid access token, code, 57 | * or refresh token. 58 | */ 59 | public class AuthenticationException(message: String, cause: Throwable? = null) : SpotifyException(message, cause) { 60 | public constructor(authenticationError: AuthenticationError?) : 61 | this("Authentication error: ${authenticationError?.error}. Description: ${authenticationError?.description}") 62 | } 63 | 64 | /** 65 | * Exception signifying that the HTTP request associated with this API endpoint timed out (by default, after 100 seconds). 66 | */ 67 | public class TimeoutException(message: String, cause: Throwable? = null) : SpotifyException(message, cause) 68 | 69 | /** 70 | * Exception signifying that re-authentication via spotify-auth is necessary. Thrown by default when refreshTokenProducer is null. 71 | */ 72 | public class ReAuthenticationNeededException(cause: Throwable? = null, message: String? = null) : 73 | SpotifyException(message ?: "Re-authentication is needed.", cause) 74 | 75 | /** 76 | * Exception signifying that the current api token does not have the necessary scope to complete this request 77 | * 78 | */ 79 | public class SpotifyScopesNeededException(cause: Throwable? = null, public val missingScopes: List) : 80 | BadRequestException( 81 | cause = cause, 82 | message = "You tried to call a method that requires the following missing scopes: $missingScopes. Please make sure that your token is requested with these scopes." 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/PublicPlaylistsApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.SpotifyClientApi 9 | import com.adamratzman.spotify.SpotifyException 10 | import com.adamratzman.spotify.models.LocalTrack 11 | import com.adamratzman.spotify.models.PodcastEpisodeTrack 12 | import com.adamratzman.spotify.models.Track 13 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 14 | import kotlinx.coroutines.ExperimentalCoroutinesApi 15 | import kotlinx.coroutines.test.TestResult 16 | import kotlin.test.Test 17 | import kotlin.test.assertEquals 18 | import kotlin.test.assertFailsWith 19 | import kotlin.test.assertNull 20 | import kotlin.test.assertTrue 21 | 22 | class PublicPlaylistsApiTest : AbstractTest() { 23 | @Test 24 | fun testGetUserPlaylists(): TestResult = runTestOnDefaultDispatcher { 25 | buildApi(::testGetUserPlaylists.name) 26 | 27 | assertTrue(api.playlists.getUserPlaylists("adamratzman1").items.isNotEmpty()) 28 | assertTrue(api.playlists.getUserPlaylists("adamratzman1").items.isNotEmpty()) 29 | assertTrue(api.playlists.getUserPlaylists("adamratzman1").items.isNotEmpty()) 30 | assertTrue(api.playlists.getUserPlaylists("adamratzman1").items.isNotEmpty()) 31 | assertFailsWith { api.playlists.getUserPlaylists("non-existant-user").items.size } 32 | } 33 | 34 | @Test 35 | fun testGetPlaylist(): TestResult = runTestOnDefaultDispatcher { 36 | buildApi(::testGetPlaylist.name) 37 | 38 | assertEquals("run2", api.playlists.getPlaylist("78eWnYKwDksmCHAjOUNPEj")?.name) 39 | assertNull(api.playlists.getPlaylist("nope")) 40 | assertTrue(api.playlists.getPlaylist("78eWnYKwDksmCHAjOUNPEj")!!.tracks.isNotEmpty()) 41 | val playlistWithLocalAndNonLocalTracks = api.playlists.getPlaylist("627gNjNzj3sOrSiDm5acc2")!!.tracks 42 | assertEquals(LocalTrack::class, playlistWithLocalAndNonLocalTracks[0].track!!::class) 43 | assertEquals(Track::class, playlistWithLocalAndNonLocalTracks[1].track!!::class) 44 | 45 | if (api is SpotifyClientApi) { 46 | val playlistWithPodcastsTracks = api.playlists.getPlaylist("37i9dQZF1DX8tN3OFXtAqt")!!.tracks 47 | assertEquals(PodcastEpisodeTrack::class, playlistWithPodcastsTracks[0].track!!::class) 48 | } 49 | } 50 | 51 | @Test 52 | fun testGetPlaylistTracks(): TestResult = runTestOnDefaultDispatcher { 53 | buildApi(::testGetPlaylistTracks.name) 54 | 55 | assertTrue(api.playlists.getPlaylistTracks("78eWnYKwDksmCHAjOUNPEj").items.isNotEmpty()) 56 | val playlist = api.playlists.getPlaylistTracks("627gNjNzj3sOrSiDm5acc2") 57 | assertEquals(LocalTrack::class, playlist[0].track!!::class) 58 | assertEquals(Track::class, playlist[1].track!!::class) 59 | assertFailsWith { api.playlists.getPlaylistTracks("adskjfjkasdf") } 60 | 61 | if (api is SpotifyClientApi) { 62 | val playlistWithPodcasts = api.playlists.getPlaylistTracks("37i9dQZF1DX8tN3OFXtAqt") 63 | assertEquals(PodcastEpisodeTrack::class, playlistWithPodcasts[0].track!!::class) 64 | } 65 | } 66 | 67 | @Test 68 | fun testGetPlaylistCover(): TestResult = runTestOnDefaultDispatcher { 69 | buildApi(::testGetPlaylistCover.name) 70 | 71 | assertTrue(api.playlists.getPlaylistCovers("37i9dQZF1DXcBWIGoYBM5M").isNotEmpty()) 72 | assertFailsWith { api.playlists.getPlaylistCovers("adskjfjkasdf") } 73 | } 74 | 75 | @Test 76 | fun testConvertSimplePlaylistToPlaylist(): TestResult = runTestOnDefaultDispatcher { 77 | buildApi(::testConvertSimplePlaylistToPlaylist.name) 78 | 79 | val simplePlaylist = api.playlists.getUserPlaylists("adamratzman1").first()!! 80 | assertEquals(simplePlaylist.id, simplePlaylist.toFullPlaylist()?.id) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Deployment workflow 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | release_version: 6 | description: 'Semantic version number to release' 7 | required: true 8 | spotify_test_client_token: 9 | description: 'Spotify client redirect token (for client tests before release)' 10 | required: true 11 | spotify_test_redirect_uri: 12 | description: 'Spotify redirect uri' 13 | required: true 14 | env: 15 | SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} 16 | SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} 17 | # TODO temporarily deactivating client tests due to flaky (from spotify responses) tests 18 | #SPOTIFY_TOKEN_STRING: ${{ github.event.inputs.spotify_test_client_token }} 19 | SPOTIFY_REDIRECT_URI: ${{ github.event.inputs.spotify_test_redirect_uri }} 20 | NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }} 21 | NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} 22 | ORG_GRADLE_PROJECT_SIGNING_KEY: ${{ secrets.SIGNING_KEY }} 23 | ORG_GRADLE_PROJECT_SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} 24 | SPOTIFY_API_PUBLISH_VERSION: ${{ github.event.inputs.release_version }} 25 | jobs: 26 | release_android_jvm_linux_js: 27 | runs-on: ubuntu-latest 28 | environment: release 29 | steps: 30 | - name: Check out repo 31 | uses: actions/checkout@v2 32 | - name: Install java 11 33 | uses: actions/setup-java@v2 34 | with: 35 | distribution: 'adopt' 36 | java-version: '17' 37 | - name: Install curl 38 | run: sudo apt-get install -y curl libcurl4-openssl-dev 39 | - name: Verify Android 40 | run: ./gradlew testDebugUnitTest 41 | - name: Verify JVM/JS 42 | run: ./gradlew jvmTest 43 | - name: Publish JVM/Linux/Android 44 | run: ./gradlew publishKotlinMultiplatformPublicationToNexusRepository publishJvmPublicationToNexusRepository publishAndroidPublicationToNexusRepository publishLinuxX64PublicationToNexusRepository publishJsPublicationToNexusRepository 45 | - name: Archive test results 46 | uses: actions/upload-artifact@v2 47 | with: 48 | name: code-coverage-report 49 | path: build/reports 50 | if: always() 51 | release_mac: 52 | runs-on: macos-latest 53 | environment: release 54 | needs: release_android_jvm_linux_js 55 | steps: 56 | - name: Check out repo 57 | uses: actions/checkout@v2 58 | - name: Install java 11 59 | uses: actions/setup-java@v2 60 | with: 61 | distribution: 'adopt' 62 | java-version: '17' 63 | - name: Publish macOS/iOS 64 | run: ./gradlew publishMacosX64PublicationToNexusRepository publishIosX64PublicationToNexusRepository publishIosArm64PublicationToNexusRepository 65 | release_windows: 66 | runs-on: windows-latest 67 | environment: release 68 | needs: release_android_jvm_linux_js 69 | steps: 70 | - name: Check out repo 71 | uses: actions/checkout@v2 72 | - name: Install java 11 73 | uses: actions/setup-java@v2 74 | with: 75 | distribution: 'adopt' 76 | java-version: '17' 77 | - run: choco install curl 78 | - name: Publish windows 79 | run: ./gradlew publishMingwX64PublicationToNexusRepository 80 | release_docs: 81 | runs-on: ubuntu-latest 82 | environment: release 83 | steps: 84 | - name: Check out repo 85 | uses: actions/checkout@v2 86 | - name: Install java 11 87 | uses: actions/setup-java@v2 88 | with: 89 | distribution: 'adopt' 90 | java-version: '17' 91 | - name: Build docs 92 | run: ./gradlew dokkaHtml 93 | - name: Push docs to docs repo 94 | uses: cpina/github-action-push-to-another-repository@main 95 | env: 96 | API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }} 97 | with: 98 | source-directory: 'docs' 99 | destination-github-username: 'adamint' 100 | destination-repository-name: 'spotify-web-api-kotlin-docs' 101 | user-email: adam@adamratzman.com 102 | target-branch: main 103 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/pub/EpisodeApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.pub 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyAppApi 6 | import com.adamratzman.spotify.SpotifyClientApi 7 | import com.adamratzman.spotify.SpotifyException.BadRequestException 8 | import com.adamratzman.spotify.SpotifyScope 9 | import com.adamratzman.spotify.http.SpotifyEndpoint 10 | import com.adamratzman.spotify.models.Episode 11 | import com.adamratzman.spotify.models.EpisodeList 12 | import com.adamratzman.spotify.models.EpisodeUri 13 | import com.adamratzman.spotify.models.serialization.toObject 14 | import com.adamratzman.spotify.utils.Market 15 | import com.adamratzman.spotify.utils.catch 16 | import com.adamratzman.spotify.utils.encodeUrl 17 | import com.adamratzman.spotify.utils.getSpotifyId 18 | 19 | /** 20 | * Endpoints for retrieving information about one or more episodes from the Spotify catalog. 21 | * 22 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/episodes/)** 23 | */ 24 | public open class EpisodeApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) { 25 | /** 26 | * Get Spotify catalog information for a single episode identified by its unique Spotify ID. 27 | * 28 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 29 | * 30 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/episodes/get-an-episode/)** 31 | * 32 | * @param id The Spotify ID for the episode. 33 | * @param market If a country code is specified, only shows and episodes that are available in that market will be returned. 34 | * If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. 35 | * Note: If neither market or user country are provided, the content is considered unavailable for the client. 36 | * Users can view the country that is associated with their account in the account settings. Required for [SpotifyAppApi], but **you may use [Market.FROM_TOKEN] to get the user market** 37 | * 38 | * @return possibly-null [Episode]. 39 | */ 40 | public suspend fun getEpisode(id: String, market: Market): Episode? { 41 | return catch { 42 | get( 43 | endpointBuilder("/episodes/${EpisodeUri(id).id.encodeUrl()}").with("market", market.getSpotifyId()).toString() 44 | ).toObject(Episode.serializer(), api, json) 45 | } 46 | } 47 | 48 | /** 49 | * Get Spotify catalog information for multiple episodes based on their Spotify IDs. 50 | * 51 | * **Invalid episode ids will result in a [BadRequestException] 52 | * 53 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 54 | * 55 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/episodes/get-several-episodes/)** 56 | * 57 | * @param ids The id or uri for the episodes. Maximum **50**. 58 | * @param market If a country code is specified, only shows and episodes that are available in that market will be returned. 59 | * If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. 60 | * Note: If neither market or user country are provided, the content is considered unavailable for the client. 61 | * Users can view the country that is associated with their account in the account settings. Required for [SpotifyAppApi], but **you may use [Market.FROM_TOKEN] to get the user market** 62 | * 63 | * @return List of possibly-null [Episode] objects. 64 | * @throws BadRequestException If any invalid show id is provided, if this is a [SpotifyClientApi] 65 | */ 66 | public suspend fun getEpisodes(vararg ids: String, market: Market): List { 67 | checkBulkRequesting(50, ids.size) 68 | 69 | return bulkStatelessRequest(50, ids.toList()) { chunk -> 70 | get( 71 | endpointBuilder("/episodes").with("ids", chunk.joinToString(",") { EpisodeUri(it).id.encodeUrl() }) 72 | .with("market", market.getSpotifyId()).toString() 73 | ).toObject(EpisodeList.serializer(), api, json).episodes 74 | }.flatten() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/client/ClientShowApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.client 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyException.BadRequestException 6 | import com.adamratzman.spotify.SpotifyScope 7 | import com.adamratzman.spotify.endpoints.pub.ShowApi 8 | import com.adamratzman.spotify.models.PagingObject 9 | import com.adamratzman.spotify.models.Show 10 | import com.adamratzman.spotify.models.ShowList 11 | import com.adamratzman.spotify.models.ShowUri 12 | import com.adamratzman.spotify.models.SimpleEpisode 13 | import com.adamratzman.spotify.models.SimpleShow 14 | import com.adamratzman.spotify.models.serialization.toNonNullablePagingObject 15 | import com.adamratzman.spotify.models.serialization.toObject 16 | import com.adamratzman.spotify.utils.Market 17 | import com.adamratzman.spotify.utils.catch 18 | import com.adamratzman.spotify.utils.encodeUrl 19 | 20 | /** 21 | * Endpoints for retrieving information about one or more shows and their episodes from the Spotify catalog. 22 | * 23 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/shows/)** 24 | */ 25 | public class ClientShowApi(api: GenericSpotifyApi) : ShowApi(api) { 26 | /** 27 | * Get Spotify catalog information for a single show identified by its unique Spotify ID. The [Market] associated with 28 | * the user account will be used. 29 | * 30 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 31 | * 32 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-track/)** 33 | * 34 | * @param id The Spotify ID for the show. 35 | * 36 | * @return possibly-null Show. This behavior is *not the same* as in [getShows] 37 | */ 38 | public suspend fun getShow(id: String): Show? { 39 | return catch { 40 | get( 41 | endpointBuilder("/shows/${ShowUri(id).id.encodeUrl()}").toString() 42 | ).toObject(Show.serializer(), api, json) 43 | } 44 | } 45 | 46 | /** 47 | * Get Spotify catalog information for multiple shows based on their Spotify IDs. The [Market] associated with 48 | * the user account will be used. 49 | * 50 | * **Invalid show ids will result in a [BadRequestException] 51 | * 52 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 53 | * 54 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/shows/get-several-shows/)** 55 | * 56 | * @param ids The id or uri for the shows. Maximum **50**. 57 | * 58 | * @return List of possibly-null [SimpleShow] objects. 59 | * @throws BadRequestException If any invalid show id is provided 60 | */ 61 | public suspend fun getShows(vararg ids: String): List { 62 | checkBulkRequesting(50, ids.size) 63 | return bulkStatelessRequest(50, ids.toList()) { chunk -> 64 | get( 65 | endpointBuilder("/shows") 66 | .with("ids", chunk.joinToString(",") { ShowUri(it).id.encodeUrl() }) 67 | .toString() 68 | ).toObject(ShowList.serializer(), api, json).shows 69 | }.flatten() 70 | } 71 | 72 | /** 73 | * Get Spotify catalog information about an show’s episodes. The [Market] associated with 74 | * the user account will be used. 75 | * 76 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 77 | * 78 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/shows/get-shows-episodes/)** 79 | * 80 | * @param id The Spotify ID for the show. 81 | * @param limit The number of objects to return. Default: 20 (or api limit). Minimum: 1. Maximum: 50. 82 | * @param offset The index of the first item to return. Default: 0. Use with limit to get the next set of items 83 | * 84 | * @throws BadRequestException if the playlist cannot be found 85 | */ 86 | public suspend fun getShowEpisodes( 87 | id: String, 88 | limit: Int? = null, 89 | offset: Int? = null 90 | ): PagingObject = get( 91 | endpointBuilder("/shows/${ShowUri(id).id.encodeUrl()}/episodes").with("limit", limit) 92 | .with("offset", offset).toString() 93 | ).toNonNullablePagingObject(SimpleEpisode.serializer(), null, api, json) 94 | } 95 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/priv/ClientFollowingApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.priv 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.SpotifyClientApi 8 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.test.TestResult 11 | import kotlin.test.Test 12 | 13 | class ClientFollowingApiTest : AbstractTest() { 14 | @Test 15 | fun testFollowUnfollowArtists(): TestResult = runTestOnDefaultDispatcher { 16 | return@runTestOnDefaultDispatcher // TODO https://github.com/adamint/spotify-web-api-kotlin/issues/309 17 | 18 | /*buildApi(SpotifyClientApi::class) 19 | if (!isApiInitialized()) return@runTestOnDefaultDispatcher 20 | 21 | val testArtistId = "7eCmccnRwPmRnWPw61x6jM" 22 | if (api!!.following.isFollowingArtist(testArtistId)) { 23 | api!!.following.unfollowArtist(testArtistId) 24 | } 25 | 26 | assertTrue(!api!!.following.isFollowingArtist(testArtistId)) 27 | 28 | val beforeFollowing = api!!.following.getFollowedArtists().getAllItemsNotNull() 29 | 30 | assertNull(beforeFollowing.find { it.id == testArtistId }) 31 | 32 | api!!.following.followArtist(testArtistId) 33 | api!!.following.followArtist(testArtistId) 34 | 35 | assertTrue(api!!.following.isFollowingArtist(testArtistId)) 36 | 37 | assertEquals(1, api!!.following.getFollowedArtists().getAllItems().size - beforeFollowing.size) 38 | 39 | api!!.following.unfollowArtist(testArtistId) 40 | api!!.following.unfollowArtist(testArtistId) 41 | 42 | assertEquals(beforeFollowing.size, api!!.following.getFollowedArtists().getAllItems().size) 43 | 44 | assertTrue(!api!!.following.isFollowingArtist(testArtistId)) 45 | 46 | assertFailsWith { api!!.following.isFollowingArtist("no u") } 47 | assertFailsWith { api!!.following.followArtist("no u") } 48 | assertFailsWith { 49 | api!!.following.followArtists( 50 | testArtistId, 51 | "no u" 52 | ) 53 | } 54 | assertFailsWith { api!!.following.unfollowArtist("no u") }*/ 55 | } 56 | 57 | @Test 58 | fun testFollowUnfollowUsers(): TestResult = runTestOnDefaultDispatcher { 59 | return@runTestOnDefaultDispatcher // TODO https://github.com/adamint/spotify-web-api-kotlin/issues/309 60 | 61 | /*buildApi(SpotifyClientApi::class) 62 | if (!isApiInitialized()) return@runTestOnDefaultDispatcher 63 | 64 | val testUserId = "adamratzman" 65 | 66 | if (api!!.following.isFollowingUser(testUserId)) { 67 | api!!.following.unfollowUser(testUserId) 68 | } 69 | 70 | api!!.following.followUser(testUserId) 71 | 72 | assertTrue(api!!.following.isFollowingUser(testUserId)) 73 | 74 | api!!.following.unfollowUser(testUserId) 75 | 76 | assertFalse(api!!.following.isFollowingUser(testUserId))*/ 77 | } 78 | 79 | @Test 80 | fun testFollowUnfollowPlaylists(): TestResult = runTestOnDefaultDispatcher { 81 | return@runTestOnDefaultDispatcher // TODO https://github.com/adamint/spotify-web-api-kotlin/issues/309 82 | 83 | /*buildApi(SpotifyClientApi::class) 84 | if (!isApiInitialized()) return@runTestOnDefaultDispatcher 85 | 86 | val playlistId = "37i9dQZF1DXcBWIGoYBM5M" 87 | if (api!!.following.isFollowingPlaylist(playlistId)) { 88 | api!!.following.unfollowPlaylist(playlistId) 89 | } 90 | 91 | assertFalse(api!!.following.isFollowingPlaylist(playlistId)) 92 | 93 | api!!.following.followPlaylist(playlistId) 94 | 95 | assertTrue(api!!.following.isFollowingPlaylist(playlistId)) 96 | 97 | assertFailsWith { 98 | api!!.following.isFollowingPlaylist( 99 | " no u", 100 | "no u" 101 | ) 102 | } 103 | assertFailsWith { api!!.following.unfollowPlaylist("no-u") } 104 | assertFailsWith { api!!.following.followPlaylist("nou") }*/ 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/commonJvmLikeTest/kotlin/com/adamratzman/spotify/CommonImpl.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify 3 | 4 | import com.adamratzman.spotify.http.HttpRequest 5 | import com.adamratzman.spotify.http.HttpResponse 6 | import kotlinx.serialization.encodeToString 7 | import kotlinx.serialization.json.Json 8 | import java.io.File 9 | 10 | val cacheLocation: String? = System.getenv("RESPONSE_CACHE_DIR") 11 | val shouldRecacheRequests: Boolean = System.getenv("SHOULD_RECACHE_RESPONSES")?.toBoolean() == true 12 | 13 | actual fun getTestClientId(): String? = System.getenv("SPOTIFY_CLIENT_ID") 14 | actual fun getTestClientSecret(): String? = System.getenv("SPOTIFY_CLIENT_SECRET") 15 | actual fun getTestRedirectUri(): String? = System.getenv("SPOTIFY_REDIRECT_URI") 16 | actual fun getTestTokenString(): String? = System.getenv("SPOTIFY_TOKEN_STRING") 17 | actual fun isHttpLoggingEnabled(): Boolean = System.getenv("SPOTIFY_LOG_HTTP") == "true" 18 | actual fun arePlayerTestsEnabled(): Boolean = System.getenv("SPOTIFY_ENABLE_PLAYER_TESTS")?.toBoolean() == true 19 | actual fun areLivePkceTestsEnabled(): Boolean = System.getenv("VERBOSE_TEST_ENABLED")?.toBoolean() ?: false 20 | 21 | var hasInstantiatedApi: Boolean = false 22 | var backingApi: GenericSpotifyApi? = null 23 | 24 | actual suspend fun buildSpotifyApi(testClassQualifiedName: String, testName: String): GenericSpotifyApi? { 25 | if (!hasInstantiatedApi) { 26 | backingApi = buildSpotifyApiInternal() 27 | hasInstantiatedApi = true 28 | } 29 | 30 | return backingApi; 31 | } 32 | 33 | private suspend fun buildSpotifyApiInternal(): GenericSpotifyApi? { 34 | val clientId = getTestClientId() 35 | val clientSecret = getTestClientSecret() 36 | val tokenString = getTestTokenString() 37 | val logHttp = isHttpLoggingEnabled() 38 | 39 | val optionsCreator: (SpotifyApiOptions.() -> Unit) = { 40 | this.enableDebugMode = logHttp 41 | retryOnInternalServerErrorTimes = 0 42 | } 43 | 44 | return when { 45 | tokenString?.isNotBlank() == true -> { 46 | spotifyClientApi { 47 | credentials { 48 | this.clientId = clientId 49 | this.clientSecret = clientSecret 50 | this.redirectUri = getTestRedirectUri() 51 | } 52 | authorization { 53 | this.tokenString = tokenString 54 | } 55 | options(optionsCreator) 56 | }.build() 57 | } 58 | 59 | clientId?.isNotBlank() == true -> { 60 | spotifyAppApi { 61 | credentials { 62 | this.clientId = clientId 63 | this.clientSecret = clientSecret 64 | } 65 | options(optionsCreator) 66 | }.build() 67 | } 68 | 69 | else -> null 70 | } 71 | } 72 | 73 | object JvmResponseCacher : ResponseCacher { 74 | override val cachedResponsesDirectoryPath: String = cacheLocation ?: "" 75 | private val json = Json { prettyPrint = true } 76 | private val baseDirectory = File(cacheLocation) 77 | 78 | init { 79 | if (baseDirectory.exists()) baseDirectory.deleteRecursively() 80 | baseDirectory.mkdirs() 81 | } 82 | 83 | override fun cacheResponse( 84 | className: String, 85 | testName: String, 86 | responseNumber: Int, 87 | request: HttpRequest, 88 | response: HttpResponse 89 | ) { 90 | val testDirectory = File(baseDirectory.absolutePath + "/$className/$testName") 91 | if (!testDirectory.exists()) testDirectory.mkdirs() 92 | 93 | val responseFile = File(testDirectory.absolutePath + "/http_request_$responseNumber.txt") 94 | if (responseFile.exists()) responseFile.delete() 95 | responseFile.createNewFile() 96 | 97 | val objToWrite = CachedResponse( 98 | Request( 99 | request.url, 100 | request.method.toString(), 101 | request.bodyString 102 | ), 103 | Response( 104 | response.responseCode, 105 | response.headers.associate { it.key to it.value }, 106 | response.body 107 | ) 108 | ) 109 | 110 | responseFile.appendText(json.encodeToString(objToWrite)) 111 | } 112 | } 113 | 114 | actual fun getResponseCacher(): ResponseCacher? { 115 | if (cacheLocation == null || !shouldRecacheRequests) return null 116 | return JvmResponseCacher 117 | } 118 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/pub/AlbumApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.pub 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyException.BadRequestException 6 | import com.adamratzman.spotify.http.SpotifyEndpoint 7 | import com.adamratzman.spotify.models.Album 8 | import com.adamratzman.spotify.models.AlbumUri 9 | import com.adamratzman.spotify.models.AlbumsResponse 10 | import com.adamratzman.spotify.models.PagingObject 11 | import com.adamratzman.spotify.models.SimpleTrack 12 | import com.adamratzman.spotify.models.serialization.toNonNullablePagingObject 13 | import com.adamratzman.spotify.models.serialization.toObject 14 | import com.adamratzman.spotify.utils.Market 15 | import com.adamratzman.spotify.utils.catch 16 | import com.adamratzman.spotify.utils.encodeUrl 17 | import com.adamratzman.spotify.utils.getSpotifyId 18 | 19 | /** 20 | * Endpoints for retrieving information about one or more albums from the Spotify catalog. 21 | * 22 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/albums/)** 23 | */ 24 | public class AlbumApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) { 25 | /** 26 | * Get Spotify catalog information for a single album. 27 | * 28 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/albums/get-album/)** 29 | * 30 | * @param album The id or uri for the album. 31 | * @param market Provide this parameter if you want to apply [Track Relinking](https://github.com/adamint/spotify-web-api-kotlin#track-relinking) 32 | * 33 | * @return Full [Album] object if the provided id is found, otherwise null 34 | */ 35 | public suspend fun getAlbum(album: String, market: Market? = null): Album? = catch { 36 | get( 37 | endpointBuilder("/albums/${AlbumUri(album).id}").with( 38 | "market", 39 | market?.getSpotifyId() 40 | ).toString() 41 | ).toObject(Album.serializer(), api, json) 42 | } 43 | 44 | /** 45 | * Get Spotify catalog information for multiple albums identified by their Spotify IDs. 46 | * **Albums not found are returned as null inside the ordered list** 47 | * 48 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/albums/get-several-albums/)** 49 | * 50 | * @param albums The ids or uris for the albums. Maximum **20**. 51 | * @param market Provide this parameter if you want to apply [Track Relinking](https://github.com/adamint/spotify-web-api-kotlin#track-relinking) 52 | * 53 | * @return List of [Album] objects or null if the album could not be found, in the order requested 54 | */ 55 | public suspend fun getAlbums(vararg albums: String, market: Market? = null): List { 56 | checkBulkRequesting(20, albums.size) 57 | return bulkStatelessRequest(20, albums.toList()) { chunk -> 58 | get( 59 | endpointBuilder("/albums").with("ids", chunk.joinToString(",") { AlbumUri(it).id.encodeUrl() }) 60 | .with("market", market?.getSpotifyId()).toString() 61 | ).toObject(AlbumsResponse.serializer(), api, json).albums 62 | }.flatten() 63 | } 64 | 65 | /** 66 | * Get Spotify catalog information about an album’s tracks. Optional parameters can be used to limit the number of tracks returned. 67 | * 68 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/albums/get-albums-tracks/)** 69 | * 70 | * @param album The id or uri for the album. 71 | * @param limit The number of objects to return. Default: 50 (or api limit). Minimum: 1. Maximum: 50. 72 | * @param offset The index of the first item to return. Default: 0. Use with limit to get the next set of items 73 | * @param market Provide this parameter if you want to apply [Track Relinking](https://github.com/adamint/spotify-web-api-kotlin#track-relinking) 74 | * 75 | * @throws [BadRequestException] if the [album] is not found, or positioning of [limit] or [offset] is illegal. 76 | * @return [PagingObject] of [SimpleTrack] objects 77 | */ 78 | public suspend fun getAlbumTracks( 79 | album: String, 80 | limit: Int? = api.spotifyApiOptions.defaultLimit, 81 | offset: Int? = null, 82 | market: Market? = null 83 | ): PagingObject = get( 84 | endpointBuilder("/albums/${AlbumUri(album).id.encodeUrl()}/tracks").with("limit", limit).with( 85 | "offset", 86 | offset 87 | ).with("market", market?.getSpotifyId()) 88 | .toString() 89 | ).toNonNullablePagingObject(SimpleTrack.serializer(), api = api, json = json) 90 | } 91 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/pub/SearchApiTest.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.pub 5 | 6 | import com.adamratzman.spotify.AbstractTest 7 | import com.adamratzman.spotify.GenericSpotifyApi 8 | import com.adamratzman.spotify.SpotifyException 9 | import com.adamratzman.spotify.assertFailsWithSuspend 10 | import com.adamratzman.spotify.endpoints.pub.SearchApi 11 | import com.adamratzman.spotify.models.SearchFilter 12 | import com.adamratzman.spotify.models.SearchFilterType.Artist 13 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 14 | import com.adamratzman.spotify.utils.Market 15 | import kotlinx.coroutines.ExperimentalCoroutinesApi 16 | import kotlinx.coroutines.test.TestResult 17 | import kotlin.test.Test 18 | import kotlin.test.assertFailsWith 19 | import kotlin.test.assertTrue 20 | 21 | class SearchApiTest : AbstractTest() { 22 | @Test 23 | fun testSearchMultiple(): TestResult = runTestOnDefaultDispatcher { 24 | buildApi(::testSearchMultiple.name) 25 | 26 | val query = api.search.search("lo", *SearchApi.SearchType.entries.toTypedArray(), market = Market.US) 27 | assertTrue( 28 | query.albums?.items?.isNotEmpty() == true && query.tracks?.items?.isNotEmpty() == true && query.artists?.items?.isNotEmpty() == true && 29 | query.playlists?.items?.isNotEmpty() == true && query.shows?.items?.isNotEmpty() == true && query.episodes?.items?.isNotEmpty() == true 30 | ) 31 | val query2 = api.search.search("lo", SearchApi.SearchType.Artist, SearchApi.SearchType.Playlist) 32 | assertTrue( 33 | query2.albums == null && query2.tracks == null && query2.shows == null && query2.episodes == null && 34 | query2.artists?.items?.isNotEmpty() == true && query2.playlists?.items?.isNotEmpty() == true 35 | ) 36 | val query3 = 37 | api.search.search("lo", SearchApi.SearchType.Show, SearchApi.SearchType.Episode, market = Market.US) 38 | assertTrue(query3.episodes?.items?.isNotEmpty() == true && query3.shows?.items?.isNotEmpty() == true) 39 | } 40 | 41 | @Test 42 | fun testSearchTrack(): TestResult = runTestOnDefaultDispatcher { 43 | buildApi(::testSearchTrack.name) 44 | 45 | assertTrue(api.search.searchTrack("hello", listOf(SearchFilter(Artist, "Lionel Ritchie")), 1, 1, Market.US).items.isNotEmpty()) 46 | assertFailsWith { api.search.searchTrack("").items.size } 47 | } 48 | 49 | @Test 50 | fun testSearchAlbum(): TestResult = runTestOnDefaultDispatcher { 51 | buildApi(::testSearchAlbum.name) 52 | 53 | assertTrue(api.search.searchAlbum("le début").items.isNotEmpty()) 54 | assertFailsWith { api.search.searchAlbum("").items.size } 55 | } 56 | 57 | @Test 58 | fun testSearchPlaylist(): TestResult = runTestOnDefaultDispatcher { 59 | buildApi(::testSearchPlaylist.name) 60 | 61 | assertTrue(api.search.searchPlaylist("test").items.isNotEmpty()) 62 | assertFailsWithSuspend { api.search.searchPlaylist("").items.size } 63 | } 64 | 65 | @Test 66 | fun testSearchArtist(): TestResult = runTestOnDefaultDispatcher { 67 | buildApi(::testSearchArtist.name) 68 | 69 | assertTrue(api.search.searchArtist("amir").items.isNotEmpty()) 70 | assertFailsWith { api.search.searchArtist("").items.size } 71 | } 72 | 73 | @Test 74 | fun testSearchShow(): TestResult = runTestOnDefaultDispatcher { 75 | buildApi(::testSearchShow.name) 76 | 77 | (api.search as? SearchApi)?.let { clientSearchApi -> 78 | assertTrue(clientSearchApi.searchShow("f", market = Market.US).items.isNotEmpty()) 79 | assertFailsWith { 80 | clientSearchApi.searchShow( 81 | "", 82 | market = Market.US 83 | ).items.size 84 | } 85 | } 86 | } 87 | 88 | @Test 89 | fun testSearchEpisode(): TestResult = runTestOnDefaultDispatcher { 90 | buildApi(::testSearchEpisode.name) 91 | 92 | (api.search as? SearchApi)?.let { clientSearchApi -> 93 | assertTrue(clientSearchApi.searchEpisode("f", market = Market.US).items.isNotEmpty()) 94 | assertFailsWith { 95 | clientSearchApi.searchEpisode( 96 | "", 97 | market = Market.US 98 | ).items.size 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/LocalTracks.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import com.adamratzman.spotify.SpotifyRestAction 5 | import com.adamratzman.spotify.utils.Market 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | 9 | /** 10 | * Local artist object (goes with [LocalTrack]) representing an artist on a local track 11 | * 12 | * @param name The name of the artist 13 | * @param type The object type: "artist" 14 | */ 15 | @Serializable 16 | public data class SimpleLocalArtist( 17 | val name: String, 18 | val type: String 19 | ) 20 | 21 | /** 22 | * Local album object that goes with [LocalTrack] - represents the local album it was obtained from (likely "Local Files") 23 | * 24 | * @param artists The artists of the album. 25 | * @param name The name of the album. In case of an album takedown, the value may be an empty string. 26 | * @param type The object type: “album” 27 | * @param releaseDate The date the album was first released, for example 1981. Depending on the precision, 28 | * it might be shown as 1981-12 or 1981-12-15. 29 | * @param releaseDatePrecision The precision with which release_date value is known: year , month , or day. 30 | * @param albumType The type of the album: one of “album”, “single”, or “compilation”. 31 | */ 32 | @Serializable 33 | public data class SimpleLocalAlbum( 34 | @SerialName("album_type") val albumType: String? = null, 35 | val artists: List = listOf(), 36 | val name: String, 37 | @SerialName("release_date") private val releaseDate: String? = null, 38 | @SerialName("release_date_precision") val releaseDatePrecision: String? = null, 39 | val type: String 40 | ) 41 | 42 | /** 43 | * Local track object that representing a song uploaded from a client locally 44 | * 45 | * @param artists The artists who performed the track. 46 | * @param discNumber The disc number. 47 | * @param durationMs The track length in milliseconds. 48 | * @param explicit Whether or not the track has explicit lyrics ( true = yes it does; false = no it does not OR unknown). 49 | * @param href A link to the Web API endpoint providing full details of the track. 50 | * @param id The Spotify ID for the track. 51 | * @param name The name of the track. 52 | * @param trackNumber The number of the track. If an album has several discs, the track number 53 | * is the number on the specified disc. 54 | * @param type The object type: “track”. 55 | * @param isLocal Whether or not the track is from a local file. 56 | * @param popularity the popularity of this track. possibly null 57 | */ 58 | @Serializable 59 | public data class LocalTrack( 60 | val album: SimpleLocalAlbum, 61 | val artists: List, 62 | override val href: String? = null, 63 | override val id: String? = null, 64 | @SerialName("disc_number") val discNumber: String? = null, 65 | @SerialName("duration_ms") val durationMs: Int? = null, 66 | @SerialName("explicit") val explicit: Boolean? = null, 67 | @SerialName("is_local") val isLocal: Boolean = true, 68 | val name: String, 69 | val popularity: Double? = null, 70 | @SerialName("track_number") val trackNumber: Int? = null, 71 | override val type: String, 72 | override val uri: LocalTrackUri 73 | ) : IdentifiableNullable(), Playable { 74 | 75 | /** 76 | * Search for this local track by name in Spotify's track catalog. 77 | * 78 | * @param limit The number of objects to return. Default: 50 (or api limit). Minimum: 1. Maximum: 50. 79 | * @param offset The index of the first item to return. Default: 0. Use with limit to get the next set of items 80 | * @param market Provide this parameter if you want the list of returned items to be relevant to a particular country. 81 | * If omitted, the returned items will be relevant to all countries. 82 | */ 83 | public suspend fun searchForSpotifyTrack( 84 | limit: Int? = null, 85 | offset: Int? = null, 86 | market: Market? = null 87 | ): PagingObject = api.search.searchTrack(name, limit = limit, offset = offset, market = market) 88 | 89 | /** 90 | * Search for this local track by name in Spotify's track catalog. 91 | * 92 | * @param limit The number of objects to return. Default: 50 (or api limit). Minimum: 1. Maximum: 50. 93 | * @param offset The index of the first item to return. Default: 0. Use with limit to get the next set of items 94 | * @param market Provide this parameter if you want the list of returned items to be relevant to a particular country. 95 | * If omitted, the returned items will be relevant to all countries. 96 | */ 97 | public fun searchForSpotifyTrackRestAction( 98 | limit: Int? = null, 99 | offset: Int? = null, 100 | market: Market? = null 101 | ): SpotifyRestAction> = SpotifyRestAction { searchForSpotifyTrack(limit, offset, market) } 102 | } 103 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/SpotifyRestAction.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify 3 | 4 | import com.adamratzman.spotify.utils.TimeUnit 5 | import com.adamratzman.spotify.utils.getCurrentTimeMs 6 | import com.adamratzman.spotify.utils.runBlockingOnJvmAndNative 7 | import kotlinx.coroutines.CancellationException 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.DelicateCoroutinesApi 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.GlobalScope 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.launch 14 | import kotlinx.coroutines.withContext 15 | import kotlin.coroutines.CoroutineContext 16 | import kotlin.coroutines.resume 17 | import kotlin.coroutines.resumeWithException 18 | import kotlin.coroutines.suspendCoroutine 19 | import kotlin.jvm.JvmOverloads 20 | 21 | /** 22 | * Provides a uniform interface to retrieve, whether synchronously or asynchronously, [T] from Spotify 23 | */ 24 | public open class SpotifyRestAction internal constructor(public val supplier: suspend () -> T) { 25 | private var hasRunBacking: Boolean = false 26 | private var hasCompletedBacking: Boolean = false 27 | 28 | /** 29 | * Whether this REST action has been *commenced*. 30 | * 31 | * Not to be confused with [hasCompleted] 32 | */ 33 | public fun hasRun(): Boolean = hasRunBacking 34 | 35 | /** 36 | * Whether this REST action has been fully *completed* 37 | */ 38 | public fun hasCompleted(): Boolean = hasCompletedBacking 39 | 40 | /** 41 | * Invoke [supplier] and synchronously retrieve [T]. This is only available on JVM/Native and will fail on JS. 42 | */ 43 | public fun complete(): T = runBlockingOnJvmAndNative { 44 | suspendComplete() 45 | } 46 | 47 | /** 48 | * Suspend the coroutine, invoke [SpotifyRestAction.supplier] asynchronously/queued and resume with result [T] 49 | * */ 50 | public suspend fun suspendQueue(): T = suspendCoroutine { continuation -> 51 | queue({ throwable -> 52 | continuation.resumeWithException(throwable) 53 | }) { result -> 54 | continuation.resume(result) 55 | } 56 | } 57 | 58 | /** 59 | * Switch to given [context][context], invoke [SpotifyRestAction.supplier] and synchronously retrieve [T] 60 | * 61 | * @param context The context to execute the [SpotifyRestAction.complete] in 62 | * */ 63 | @Suppress("UNCHECKED_CAST") 64 | @JvmOverloads 65 | public suspend fun suspendComplete(context: CoroutineContext = Dispatchers.Default): T = withContext(context) { 66 | hasRunBacking = true 67 | return@withContext try { 68 | supplier().also { hasCompletedBacking = true } 69 | } catch (e: CancellationException) { 70 | throw e 71 | } catch (e: Throwable) { 72 | throw e 73 | } 74 | } 75 | 76 | /** 77 | * Invoke [supplier] asynchronously and consume [consumer] with the [T] value returned 78 | * 79 | * @param failure Consumer to invoke when an exception is thrown by [supplier] 80 | * @param consumer to be invoked with [T] after successful completion of [supplier] 81 | */ 82 | @OptIn(DelicateCoroutinesApi::class) 83 | @JvmOverloads 84 | public fun queue(failure: ((Throwable) -> Unit) = { throw it }, consumer: ((T) -> Unit) = {}) { 85 | hasRunBacking = true 86 | GlobalScope.launch { 87 | try { 88 | val result = suspendComplete() 89 | consumer(result) 90 | } catch (e: CancellationException) { 91 | throw e 92 | } catch (t: Throwable) { 93 | failure(t) 94 | } 95 | } 96 | } 97 | 98 | /** 99 | * Invoke [supplier] asynchronously immediately and invoke [consumer] after the specified quantity of time. 100 | * 101 | * @param quantity amount of time 102 | * @param timeUnit the unit that [quantity] is in 103 | * @param consumer to be invoked with [T] after successful completion of [supplier] 104 | */ 105 | @OptIn(DelicateCoroutinesApi::class) 106 | @JvmOverloads 107 | public fun queueAfter( 108 | quantity: Int, 109 | timeUnit: TimeUnit = TimeUnit.Seconds, 110 | scope: CoroutineScope = GlobalScope, 111 | failure: (Throwable) -> Unit = { throw it }, 112 | consumer: (T) -> Unit 113 | ) { 114 | val runAt = getCurrentTimeMs() + timeUnit.toMillis(quantity.toLong()) 115 | scope.launch { 116 | delay(getCurrentTimeMs() - runAt) 117 | 118 | try { 119 | consumer(suspendComplete()) 120 | } catch (e: CancellationException) { 121 | throw e 122 | } catch (t: Throwable) { 123 | failure(t) 124 | } 125 | } 126 | } 127 | 128 | override fun toString(): String = complete().toString() 129 | } 130 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/SpotifyScope.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify 3 | 4 | /** 5 | * Scopes provide Spotify users using third-party apps the confidence 6 | * that only the information they choose to share will be shared, and nothing more. 7 | * 8 | * Each represents a distinct privilege and may be required by one or more endpoints as discussed 9 | * on the [Spotify Authorization Documentation](https://developer.spotify.com/documentation/general/guides/scopes/) 10 | * 11 | * @param uri The scope id 12 | */ 13 | public enum class SpotifyScope(public val uri: String) { 14 | /** 15 | * Remote control playback of Spotify. This scope is currently available to Spotify iOS and Android App Remote SDKs. 16 | * 17 | * **Visible to users**: Communicate with the Spotify app on your device. 18 | */ 19 | AppRemoteControl("app-remote-control"), 20 | 21 | /** 22 | * Read access to user's private playlists. 23 | * 24 | * **Visible to users**: Access your private playlists. 25 | */ 26 | PlaylistReadPrivate("playlist-read-private"), 27 | 28 | /** 29 | * Include collaborative playlists when requesting a user's playlists. 30 | * 31 | * **Visible to users**: Access your collaborative playlists. 32 | */ 33 | PlaylistReadCollaborative("playlist-read-collaborative"), 34 | 35 | /** 36 | * Write access to a user's public playlists. 37 | * 38 | * **Visible to users**: Manage your public playlists. 39 | */ 40 | PlaylistModifyPublic("playlist-modify-public"), 41 | 42 | /** 43 | * Write access to a user's private playlists. 44 | * 45 | * **Visible to users**: Manage your private playlists. 46 | */ 47 | PlaylistModifyPrivate("playlist-modify-private"), 48 | 49 | /** 50 | * Control playback of a Spotify track. This scope is currently available to Spotify Playback SDKs, including the iOS SDK, Android SDK and Web Playback SDK. The user must have a Spotify Premium account. 51 | * 52 | * **Visible to users**: Play music and control playback on your other devices. 53 | */ 54 | Streaming("streaming"), 55 | 56 | /** 57 | * Let the application upload playlist covers and profile images 58 | * 59 | * **Visible to users**: Upload images to personalize your profile or playlist cover 60 | */ 61 | UgcImageUpload("ugc-image-upload"), 62 | 63 | /** 64 | * Write/delete access to the list of artists and other users that the user follows. 65 | * 66 | * **Visible to users**: Manage who you are following. 67 | */ 68 | UserFollowModify("user-follow-modify"), 69 | 70 | /** 71 | * Read access to the list of artists and other users that the user follows. 72 | * 73 | * **Visible to users**: Access your followers and who you are following. 74 | */ 75 | UserFollowRead("user-follow-read"), 76 | 77 | /** 78 | * Read access to a user's "Your Music" library. 79 | * 80 | * **Visible to users**: Access your saved tracks and albums. 81 | */ 82 | UserLibraryRead("user-library-read"), 83 | 84 | /** 85 | * Write/delete access to a user's "Your Music" library. 86 | * 87 | * **Visible to users**: Manage your saved tracks and albums. 88 | */ 89 | UserLibraryModify("user-library-modify"), 90 | 91 | /** 92 | * Write access to a user’s playback state 93 | * 94 | * **Visible to users**: Control playback on your Spotify clients and Spotify Connect devices. 95 | */ 96 | UserModifyPlaybackState("user-modify-playback-state"), 97 | 98 | /** 99 | * Read access to user’s subscription details (type of user account). 100 | * 101 | * **Visible to users**: Access your subscription details. 102 | */ 103 | UserReadPrivate("user-read-private"), 104 | 105 | /** 106 | * Read access to user’s email address. 107 | * 108 | * **Visible to users**: Get your real email address. 109 | */ 110 | UserReadEmail("user-read-email"), 111 | 112 | /** 113 | * Read access to a user's top artists and tracks. 114 | * 115 | * **Visible to users**: Read your top artists and tracks. 116 | */ 117 | UserTopRead("user-top-read"), 118 | 119 | /** 120 | * Read access to a user’s player state. 121 | * 122 | * **Visible to users**: Read your currently playing track and Spotify Connect devices information. 123 | */ 124 | UserReadPlaybackState("user-read-playback-state"), 125 | 126 | /** 127 | * Read access to a user’s playback position in a content. 128 | * 129 | * **Visible to users**: Read your position in content you have played. 130 | */ 131 | UserReadPlaybackPosition("user-read-playback-position"), 132 | 133 | /** 134 | * Read access to a user’s currently playing track 135 | * 136 | * **Visible to users**: Read your currently playing track 137 | */ 138 | UserReadCurrentlyPlaying("user-read-currently-playing"), 139 | 140 | /** 141 | * Read access to a user’s recently played tracks. 142 | * 143 | * **Visible to users**: Access your recently played items. 144 | */ 145 | UserReadRecentlyPlayed("user-read-recently-played"); 146 | } 147 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/ResultObjects.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyApi 6 | import com.adamratzman.spotify.SpotifyApiOptions 7 | import com.adamratzman.spotify.SpotifyException 8 | import com.adamratzman.spotify.utils.ExternalUrls 9 | import com.adamratzman.spotify.utils.getCurrentTimeMs 10 | import com.adamratzman.spotify.utils.getExternalUrls 11 | import kotlinx.serialization.SerialName 12 | import kotlinx.serialization.Serializable 13 | import kotlinx.serialization.Transient 14 | 15 | /** 16 | * Represents an identifiable Spotify object such as an Album or Recommendation Seed 17 | */ 18 | @Serializable 19 | public abstract class Identifiable : IdentifiableNullable() { 20 | abstract override val id: String 21 | } 22 | 23 | /** 24 | * Represents an identifiable Spotify object such as an Album or Recommendation Seed 25 | * 26 | * @property href A link to the Spotify web api endpoint associated with this request 27 | * @property id The Spotify id of the associated object 28 | */ 29 | @Serializable 30 | public abstract class IdentifiableNullable : NeedsApi() { 31 | public abstract val href: String? 32 | public abstract val id: String? 33 | } 34 | 35 | /** 36 | * Represents a core Spotify object such as a Track or Album 37 | * 38 | * @property uri The URI associated with the object 39 | * @property externalUrls Known external URLs for this object 40 | */ 41 | @Serializable 42 | public abstract class CoreObject : Identifiable() { 43 | protected abstract val externalUrlsString: Map 44 | abstract override val href: String 45 | public abstract val uri: SpotifyUri 46 | public val externalUrls: ExternalUrls get() = getExternalUrls(externalUrlsString) 47 | 48 | public abstract override fun getMembersThatNeedApiInstantiation(): List 49 | } 50 | 51 | /** 52 | * 53 | * Represents a response for which a relinked track could be available 54 | * 55 | * @property linkedTrack Part of the response when Track Relinking is applied and is only part of the response 56 | * if the track linking, in fact, exists. The requested track has been replaced with a different track. The track contains information about the originally requested track. 57 | */ 58 | @Serializable 59 | public abstract class RelinkingAvailableResponse : CoreObject() { 60 | @SerialName("linked_from") 61 | public abstract val linkedTrack: LinkedTrack? 62 | 63 | /** 64 | * Check if this response has been relinked. 65 | */ 66 | public fun isRelinked(): Boolean = linkedTrack != null 67 | } 68 | 69 | /** 70 | * Key/value pair mapping a name to an arbitrary url 71 | */ 72 | @Serializable 73 | public class ExternalUrl(public val name: String, public val url: String) 74 | 75 | /** 76 | * An external id linked to the result object 77 | * 78 | * @param key The identifier type, for example: 79 | - "isrc" - International Standard Recording Code 80 | - "ean" - International Article Number 81 | - "upc" - Universal Product Code 82 | * @param id An external identifier for the object. 83 | */ 84 | public class ExternalId(public val key: String, public val id: String) 85 | 86 | /** 87 | * Provide access to the underlying [SpotifyApi] 88 | * 89 | * @property api The API client associated with the request 90 | */ 91 | @Serializable 92 | public abstract class NeedsApi { 93 | @Transient 94 | public lateinit var api: GenericSpotifyApi 95 | 96 | internal open fun getMembersThatNeedApiInstantiation(): List = listOf(this) 97 | } 98 | 99 | /** 100 | * Interface that allows easy identifier retrieval for children with an implemented identifier 101 | */ 102 | public interface ResultEnum { 103 | public fun retrieveIdentifier(): Any 104 | } 105 | 106 | /** 107 | * Wraps around [ErrorObject]. Serialized raw Spotify error response 108 | * 109 | * @param error The error code and message, as returned by Spotify 110 | * @param exception The associated Kotlin exception for this error 111 | */ 112 | @Serializable 113 | public data class ErrorResponse(val error: ErrorObject, @Transient val exception: Exception? = null) 114 | 115 | /** 116 | * An endpoint exception from Spotify 117 | * 118 | * @param status The HTTP status code 119 | * @param message A short description of the cause of the error. 120 | */ 121 | @Serializable 122 | public data class ErrorObject(val status: Int, val message: String, val reason: String? = null) 123 | 124 | /** 125 | * An exception during the authentication process 126 | * 127 | * @param error Short error message 128 | * @param description More detailed description of the error 129 | */ 130 | @Serializable 131 | public data class AuthenticationError( 132 | val error: String, 133 | @SerialName("error_description") val description: String? = null 134 | ) 135 | 136 | /** 137 | * Thrown when [SpotifyApiOptions.retryWhenRateLimited] is false and requests have been ratelimited 138 | * 139 | * @param time the time, in seconds, until the next request can be sent 140 | */ 141 | public class SpotifyRatelimitedException(time: Long) : 142 | SpotifyException.UnNullableException("Calls to the Spotify API have been ratelimited for $time seconds until ${getCurrentTimeMs() + time * 1000}ms") 143 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/utilities/JsonTests.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.utilities 5 | 6 | import com.adamratzman.spotify.GenericSpotifyApi 7 | import com.adamratzman.spotify.buildSpotifyApi 8 | import com.adamratzman.spotify.models.Album 9 | import com.adamratzman.spotify.models.Artist 10 | import com.adamratzman.spotify.models.ArtistUri 11 | import com.adamratzman.spotify.models.CursorBasedPagingObject 12 | import com.adamratzman.spotify.models.PagingObject 13 | import com.adamratzman.spotify.models.Track 14 | import com.adamratzman.spotify.runTestOnDefaultDispatcher 15 | import kotlinx.coroutines.ExperimentalCoroutinesApi 16 | import kotlinx.coroutines.test.TestResult 17 | import kotlinx.serialization.builtins.nullable 18 | import kotlinx.serialization.decodeFromString 19 | import kotlinx.serialization.json.Json 20 | import kotlin.test.Test 21 | import kotlin.test.assertEquals 22 | import kotlin.test.assertTrue 23 | 24 | class JsonTests { 25 | var api: GenericSpotifyApi? = null 26 | 27 | fun testPrereq() = api != null 28 | 29 | @Test 30 | fun testArtistSerialization(): TestResult = runTestOnDefaultDispatcher { 31 | if (api == null) buildSpotifyApi(this::class.simpleName!!, ::testArtistSerialization.name)?.let { api = it } 32 | 33 | assertTrue( 34 | Json.encodeToString( 35 | Artist.serializer().nullable, 36 | api!!.artists.getArtist("spotify:artist:5WUlDfRSoLAfcVSX1WnrxN") 37 | ).isNotEmpty() 38 | ) 39 | } 40 | 41 | @Test 42 | fun testTrackSerialization(): TestResult = runTestOnDefaultDispatcher { 43 | if (api == null) buildSpotifyApi(this::class.simpleName!!, ::testTrackSerialization.name)?.let { api = it } 44 | 45 | assertTrue( 46 | Json.encodeToString( 47 | Track.serializer().nullable, 48 | api!!.tracks.getTrack("spotify:track:6kcHg7XL6SKyPNd78daRBL") 49 | ).isNotEmpty() 50 | ) 51 | } 52 | 53 | @Test 54 | fun testAlbumSerialization(): TestResult = runTestOnDefaultDispatcher { 55 | if (api == null) buildSpotifyApi(this::class.simpleName!!, ::testAlbumSerialization.name)?.let { api = it } 56 | 57 | assertTrue( 58 | Json.encodeToString( 59 | Album.serializer().nullable, 60 | api!!.albums.getAlbum("spotify:album:6ggQNps98xaXMY0OZWevEH") 61 | ).isNotEmpty() 62 | ) 63 | } 64 | 65 | @Test 66 | fun testArtistDeserialization(): TestResult = runTestOnDefaultDispatcher { 67 | if (api == null) buildSpotifyApi(this::class.simpleName!!, ::testArtistDeserialization.name)?.let { api = it } 68 | 69 | val json = 70 | """{"external_urls":{"spotify":"https://open.spotify.com/artist/5WUlDfRSoLAfcVSX1WnrxN"},"href":"https://api!!.spotify.com/v1/artists/5WUlDfRSoLAfcVSX1WnrxN","id":"5WUlDfRSoLAfcVSX1WnrxN","uri":"spotify:artist:5WUlDfRSoLAfcVSX1WnrxN","followers":{"href":null,"total":14675484},"genres":["australian dance","australian pop","dance pop","pop"],"images":[{"height":1333,"url":"https://i.scdn.co/image/652b6bb0dfaf8aa444f4414ee018699260e74306","width":1000},{"height":853,"url":"https://i.scdn.co/image/a82822ab211cbe28a0a1dbcb16902a1a8a2ea791","width":640},{"height":267,"url":"https://i.scdn.co/image/dd3e336d456172bbda56b543c5389e1490903a30","width":200},{"height":85,"url":"https://i.scdn.co/image/95a2aa98384b31336b8d56f8b470c45b12dcd550","width":64}],"name":"Sia","popularity":88,"type":"artist"}""" 71 | val artist = Json.decodeFromString(json) 72 | assertEquals(ArtistUri("spotify:artist:5WUlDfRSoLAfcVSX1WnrxN"), artist.uri) 73 | assertEquals("5WUlDfRSoLAfcVSX1WnrxN", artist.id) 74 | assertEquals("Sia", artist.name) 75 | assertEquals(88.0, artist.popularity) 76 | assertEquals("artist", artist.type) 77 | } 78 | 79 | @Test 80 | fun testPagingObjectDeserialization() = runTestOnDefaultDispatcher { 81 | val json = 82 | """{"href": "href", "items": [], "limit": 50, "next": "nextHref", "offset": 3, "previous": "previousHref", "total": 5}""" 83 | val pagingObject = Json.decodeFromString(PagingObject.serializer(Artist.serializer()), json) 84 | assertEquals("href", pagingObject.href) 85 | assertEquals(emptyList(), pagingObject.items) 86 | assertEquals(50, pagingObject.limit) 87 | assertEquals("nextHref", pagingObject.next) 88 | assertEquals(3, pagingObject.offset) 89 | assertEquals("previousHref", pagingObject.previous) 90 | assertEquals(5, pagingObject.total) 91 | } 92 | 93 | @Test 94 | fun testCursorBasedPagingObjectDeserialization() = runTestOnDefaultDispatcher { 95 | val json = 96 | """{"href": "href", "items": [], "limit": 50, "next": "nextHref", "cursors": {"after": "afterHref"}, "total": 5}""" 97 | val pagingObject = Json.decodeFromString(CursorBasedPagingObject.serializer(Artist.serializer()), json) 98 | assertEquals("href", pagingObject.href) 99 | assertEquals(emptyList(), pagingObject.items) 100 | assertEquals(50, pagingObject.limit) 101 | assertEquals("nextHref", pagingObject.next) 102 | assertEquals("afterHref", pagingObject.cursor?.after) 103 | assertEquals(5, pagingObject.total) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/Show.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import com.adamratzman.spotify.SpotifyRestAction 5 | import com.adamratzman.spotify.utils.Locale 6 | import com.adamratzman.spotify.utils.Market 7 | import kotlinx.serialization.SerialName 8 | import kotlinx.serialization.Serializable 9 | 10 | /** 11 | * Basic information about a Spotify show 12 | * 13 | * @param copyrights The copyright statements of the show. 14 | * @param description A description of the show. 15 | * @param explicit Whether or not the show has explicit content (true = yes it does; false = no it does not OR unknown). 16 | * @param images The cover art for the show in various sizes, widest first. 17 | * @param isExternallyHosted True if all of the show’s episodes are hosted outside of Spotify’s CDN. This field might be null in some cases. 18 | * @param mediaType The media type of the show. 19 | * @param name The name of the show. 20 | * @param publisher The publisher of the show. 21 | * @param type The object type: “show”. 22 | * 23 | * @property availableMarkets A list of the countries in which the show can be played, identified by their ISO 3166-1 alpha-2 code. 24 | * @property languages A list of the languages used in the show, identified by their ISO 639 code. 25 | */ 26 | @Serializable 27 | public data class SimpleShow( 28 | @SerialName("available_markets") private val availableMarketsString: List = listOf(), 29 | @SerialName("external_urls") override val externalUrlsString: Map, 30 | val copyrights: List, 31 | val description: String? = null, 32 | val explicit: Boolean, 33 | override val href: String, 34 | override val id: String, 35 | val images: List, 36 | @SerialName("is_externally_hosted") val isExternallyHosted: Boolean? = null, 37 | @SerialName("languages") private val languagesString: List, 38 | @SerialName("media_type") val mediaType: String, 39 | val name: String, 40 | val publisher: String, 41 | val type: String, 42 | override val uri: SpotifyUri 43 | ) : CoreObject() { 44 | val availableMarkets: List get() = availableMarketsString.map { Market.valueOf(it) } 45 | 46 | val languages: List get() = languagesString.map { Locale.valueOf(it.replace("-", "_")) } 47 | 48 | /** 49 | * Converts this [SimpleShow] to a full [Show] object 50 | * 51 | * @param market Provide this parameter if you want the list of returned items to be relevant to a particular country. 52 | */ 53 | public suspend fun toFullShow(market: Market): Show? = api.shows.getShow(id, market) 54 | 55 | /** 56 | * Converts this [SimpleShow] to a full [Show] object 57 | * 58 | * @param market Provide this parameter if you want the list of returned items to be relevant to a particular country. 59 | */ 60 | public fun toFullShowRestAction(market: Market): SpotifyRestAction = SpotifyRestAction { toFullShow(market) } 61 | 62 | override fun getMembersThatNeedApiInstantiation(): List = listOf(this) 63 | } 64 | 65 | /** 66 | * Information about a Spotify show, including its episodes 67 | * 68 | * @param copyrights The copyright statements of the show. 69 | * @param description A description of the show. 70 | * @param explicit Whether or not the show has explicit content (true = yes it does; false = no it does not OR unknown). 71 | * @param images The cover art for the show in various sizes, widest first. 72 | * @param isExternallyHosted True if all of the show’s episodes are hosted outside of Spotify’s CDN. This field might be null in some cases. 73 | * @param mediaType The media type of the show. 74 | * @param name The name of the show. 75 | * @param publisher The publisher of the show. 76 | * @param type The object type: “show”. 77 | * @param episodes A [NullablePagingObject] of the show’s episodes. 78 | * 79 | * @property availableMarkets A list of the countries in which the show can be played, identified by their ISO 3166-1 alpha-2 code. 80 | * @property languages A list of the languages used in the show, identified by their ISO 639 code. 81 | */ 82 | @Serializable 83 | public data class Show( 84 | @SerialName("available_markets") private val availableMarketsString: List = listOf(), 85 | val copyrights: List, 86 | val description: String? = null, 87 | val explicit: Boolean, 88 | val episodes: NullablePagingObject, 89 | @SerialName("external_urls") override val externalUrlsString: Map, 90 | override val href: String, 91 | override val id: String, 92 | val images: List, 93 | @SerialName("is_externally_hosted") val isExternallyHosted: Boolean? = null, 94 | @SerialName("languages") val languagesString: List, 95 | @SerialName("media_type") val mediaType: String, 96 | val name: String, 97 | val publisher: String, 98 | val type: String, 99 | override val uri: ShowUri 100 | ) : CoreObject() { 101 | val availableMarkets: List get() = availableMarketsString.map { Market.valueOf(it) } 102 | 103 | val languages: List get() = languagesString.map { Locale.valueOf(it.replace("-", "_")) } 104 | 105 | override fun getMembersThatNeedApiInstantiation(): List = listOf(episodes, this) 106 | } 107 | 108 | @Serializable 109 | internal data class ShowList(val shows: List) 110 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com.adamratzman/spotify/utilities/UtilityTests.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package com.adamratzman.spotify.utilities 5 | 6 | import com.adamratzman.spotify.* 7 | import io.ktor.util.* 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.test.TestResult 10 | import kotlin.test.* 11 | 12 | class UtilityTests { 13 | var api: GenericSpotifyApi? = null 14 | 15 | @Test 16 | fun testPagingObjectGetAllItems(): TestResult = runTestOnDefaultDispatcher { 17 | buildSpotifyApi(this::class.simpleName!!, ::testPagingObjectGetAllItems.name)?.let { api = it } 18 | 19 | val spotifyWfhPlaylist = api!!.playlists.getPlaylist("37i9dQZF1DWTLSN7iG21yC")!! 20 | val totalTracks = spotifyWfhPlaylist.tracks.total 21 | val allTracks = spotifyWfhPlaylist.tracks.getAllItemsNotNull() 22 | assertEquals(totalTracks, allTracks.size) 23 | } 24 | 25 | @Test 26 | fun testGeneratePkceCodeChallenge() { 27 | assertEquals( 28 | "c7jV_d4sQ658HgwINAR77Idumz1ik1lIb1JNlOva75E", 29 | getSpotifyPkceCodeChallenge("thisisaveryrandomalphanumericcodeverifierandisgreaterthan43characters") 30 | ) 31 | assertEquals( 32 | "9Y__uhKapn7GO_ElcaQpd8C3hdOyqTzAU4VXyR2iEV0", 33 | getSpotifyPkceCodeChallenge("12345678901234567890123456789012345678901234567890") 34 | ) 35 | } 36 | 37 | @Test 38 | fun testPagingObjectTakeItemsSize(): TestResult = runTestOnDefaultDispatcher { 39 | buildSpotifyApi(this::class.simpleName!!, ::testPagingObjectTakeItemsSize.name)?.let { api = it } 40 | assertEquals(24, api!!.browse.getNewReleases(limit = 12).take(24).size) 41 | } 42 | 43 | @Test 44 | fun testInvalidApiBuilderParameters() = runTestOnDefaultDispatcher { 45 | assertFailsWith { 46 | spotifyAppApi { }.build() 47 | } 48 | 49 | assertFailsWith { 50 | spotifyClientApi { }.build() 51 | } 52 | 53 | if (!PlatformUtils.IS_JVM) return@runTestOnDefaultDispatcher 54 | 55 | assertFailsWith { 56 | spotifyClientApi { 57 | credentials { 58 | clientId = getTestClientId() 59 | } 60 | }.build() 61 | } 62 | 63 | if (api is SpotifyClientApi) { 64 | assertFailsWith { 65 | spotifyClientApi { 66 | credentials { 67 | clientId = getTestClientId() 68 | clientSecret = getTestClientSecret() 69 | } 70 | }.build() 71 | } 72 | } 73 | } 74 | 75 | @Test 76 | fun testValidAppApiBuilderParameters() = runTestOnDefaultDispatcher { 77 | if (!PlatformUtils.IS_JVM) return@runTestOnDefaultDispatcher 78 | 79 | if (getTestClientId() != null && getTestClientSecret() != null) { 80 | val testApi = spotifyAppApi { 81 | credentials { 82 | clientId = getTestClientId() 83 | clientSecret = getTestClientSecret() 84 | } 85 | } 86 | 87 | testApi.build() 88 | } 89 | } 90 | 91 | @Test 92 | fun testAutomaticRefresh() = runTestOnDefaultDispatcher { 93 | if (!PlatformUtils.IS_JVM) return@runTestOnDefaultDispatcher 94 | 95 | var test = false 96 | val api = spotifyAppApi { 97 | credentials { 98 | clientId = getTestClientId() 99 | clientSecret = getTestClientSecret() 100 | } 101 | 102 | options { 103 | onTokenRefresh = { test = true } 104 | } 105 | }.build() 106 | 107 | api.token = api.token.copy(expiresIn = -1) 108 | val currentToken = api.token 109 | 110 | api.browse.getAvailableGenreSeeds() 111 | 112 | assertTrue(test) 113 | assertTrue(api.token.accessToken != currentToken.accessToken) 114 | } 115 | 116 | @Test 117 | fun testRequiredScopes(): TestResult = runTestOnDefaultDispatcher { 118 | buildSpotifyApi(this::class.simpleName!!, ::testRequiredScopes.name)?.let { api = it } 119 | if (api !is SpotifyClientApi) return@runTestOnDefaultDispatcher 120 | 121 | assertFailsWith { 122 | spotifyClientApi( 123 | api!!.clientId, 124 | api!!.clientSecret, 125 | (api as SpotifyClientApi).redirectUri, 126 | SpotifyUserAuthorization(token = api!!.token.copy(scopeString = null)) 127 | ) { requiredScopes = listOf(SpotifyScope.PlaylistReadPrivate) }.build() 128 | } 129 | } 130 | 131 | @Test 132 | fun testResponseSubscriber(): TestResult = runTestOnDefaultDispatcher { 133 | buildSpotifyApi(this::class.simpleName!!, ::testPagingObjectGetAllItems.name)?.let { api = it } 134 | val options = api!!.spotifyApiOptions 135 | val oldSubscriber = options.httpResponseSubscriber 136 | options.httpResponseSubscriber = { request, response -> 137 | assertNotNull( 138 | api!!.getCache().entries.singleOrNull { it.key.url == request.url } 139 | ) 140 | oldSubscriber?.invoke(request, response) 141 | } 142 | 143 | api!!.tracks.getTrack("6DrcMKnfMByc3RhhIvEw0F") 144 | options.httpResponseSubscriber = oldSubscriber 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/client/ClientPersonalizationApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.client 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyScope 6 | import com.adamratzman.spotify.http.SpotifyEndpoint 7 | import com.adamratzman.spotify.models.Artist 8 | import com.adamratzman.spotify.models.PagingObject 9 | import com.adamratzman.spotify.models.Track 10 | import com.adamratzman.spotify.models.serialization.toNonNullablePagingObject 11 | 12 | /** 13 | * Endpoints for retrieving information about the user’s listening habits. 14 | * 15 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/personalization/)** 16 | */ 17 | public class ClientPersonalizationApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) { 18 | /** 19 | * The time frame for which attribute affinities are computed. 20 | * 21 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/personalization/get-users-top-artists-and-tracks/)** 22 | * 23 | * @param id the Spotify id of the time frame 24 | */ 25 | public enum class TimeRange(public val id: String) { 26 | /** 27 | * Calculated from several years of data and including all new data as it becomes available 28 | */ 29 | LongTerm("long_term"), 30 | 31 | /** 32 | * Approximately last 6 months 33 | */ 34 | MediumTerm("medium_term"), 35 | 36 | /** 37 | * Approximately last 4 weeks 38 | */ 39 | ShortTerm("short_term"); 40 | 41 | override fun toString(): String = id 42 | } 43 | 44 | /** 45 | * Get the current user’s top artists based on calculated affinity. 46 | * 47 | * Affinity is a measure of the expected preference a user has for a particular track or artist. It is based on user 48 | * behavior, including play history, but does not include actions made while in incognito mode. Light or infrequent 49 | * users of Spotify may not have sufficient play history to generate a full affinity data set. As a user’s behavior 50 | * is likely to shift over time, this preference data is available over three time spans. See time_range in the 51 | * query parameter table for more information. For each time range, the top 50 tracks and artists are available 52 | * for each user. In the future, it is likely that this restriction will be relaxed. This data is typically updated 53 | * once each day for each user. 54 | * 55 | * **Requires** the [SpotifyScope.UserTopRead] scope 56 | * 57 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/personalization/get-users-top-artists-and-tracks/)** 58 | * 59 | * @param limit The number of objects to return. Default: 50 (or api limit). Minimum: 1. Maximum: 50. 60 | * @param offset The index of the first item to return. Default: 0. Use with limit to get the next set of items 61 | * @param timeRange The time range to which to compute this. The default is [TimeRange.MediumTerm] 62 | * 63 | * @return [PagingObject] of full [Artist] objects sorted by affinity 64 | */ 65 | public suspend fun getTopArtists( 66 | limit: Int? = api.spotifyApiOptions.defaultLimit, 67 | offset: Int? = null, 68 | timeRange: TimeRange? = null 69 | ): PagingObject { 70 | requireScopes(SpotifyScope.UserTopRead) 71 | 72 | return get( 73 | endpointBuilder("/me/top/artists").with("limit", limit).with("offset", offset) 74 | .with("time_range", timeRange).toString() 75 | ).toNonNullablePagingObject(Artist.serializer(), api = api, json = json) 76 | } 77 | 78 | /** 79 | * Get the current user’s top tracks based on calculated affinity. 80 | * 81 | * Affinity is a measure of the expected preference a user has for a particular track or artist. It is based on user 82 | * behavior, including play history, but does not include actions made while in incognito mode. Light or infrequent 83 | * users of Spotify may not have sufficient play history to generate a full affinity data set. As a user’s behavior 84 | * is likely to shift over time, this preference data is available over three time spans. See time_range in the 85 | * query parameter table for more information. For each time range, the top 50 tracks and artists are available 86 | * for each user. In the future, it is likely that this restriction will be relaxed. This data is typically updated 87 | * once each day for each user. 88 | * 89 | * **Requires** the [SpotifyScope.UserTopRead] scope 90 | * 91 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/personalization/get-users-top-artists-and-tracks/)** 92 | * 93 | * @param limit The number of objects to return. Default: 50 (or api limit). Minimum: 1. Maximum: 50. 94 | * @param offset The index of the first item to return. Default: 0. Use with limit to get the next set of items 95 | * @param timeRange The time range to which to compute this. The default is [TimeRange.MediumTerm] 96 | * 97 | * @return [PagingObject] of full [Track] objects sorted by affinity 98 | */ 99 | public suspend fun getTopTracks( 100 | limit: Int? = api.spotifyApiOptions.defaultLimit, 101 | offset: Int? = null, 102 | timeRange: TimeRange? = null 103 | ): PagingObject { 104 | requireScopes(SpotifyScope.UserTopRead) 105 | 106 | return get( 107 | endpointBuilder("/me/top/tracks").with("limit", limit).with("offset", offset) 108 | .with("time_range", timeRange).toString() 109 | ).toNonNullablePagingObject(Track.serializer(), api = api, json = json) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/models/Users.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.models 3 | 4 | import com.adamratzman.spotify.SpotifyScope 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.builtins.serializer 9 | import kotlinx.serialization.descriptors.SerialDescriptor 10 | import kotlinx.serialization.descriptors.buildClassSerialDescriptor 11 | import kotlinx.serialization.descriptors.element 12 | import kotlinx.serialization.encoding.* 13 | 14 | /** 15 | * Private information about a Spotify user. Each field may require a specific scope. 16 | * 17 | * @param country The country of the user, as set in the user’s account profile. An ISO 3166-1 alpha-2 18 | * country code. This field is only available when the current user has granted access to the [SpotifyScope.UserReadPrivate] scope. 19 | * @param displayName The name displayed on the user’s profile. null if not available. 20 | * @param email The user’s email address, as entered by the user when creating their account. Important! This email 21 | * address is unverified; there is no proof that it actually belongs to the user. This field is only 22 | * available when the current user has granted access to the [SpotifyScope.UserReadEmail] scope. 23 | * @param followers Information about the followers of the user. 24 | * @param href A link to the Web API endpoint for this user. 25 | * @param id The Spotify user ID for the user 26 | * @param images The user’s profile image. 27 | * @param product The user’s Spotify subscription level: “premium”, “free”, etc. 28 | * (The subscription level “open” can be considered the same as “free”.) This field is only available when the 29 | * current user has granted access to the [SpotifyScope.UserReadPrivate] scope. 30 | * @param type The object type: “user” 31 | */ 32 | @Serializable 33 | public data class SpotifyUserInformation( 34 | @SerialName("external_urls") override val externalUrlsString: Map, 35 | override val href: String, 36 | override val id: String, 37 | override val uri: UserUri, 38 | 39 | val country: String? = null, 40 | @SerialName("display_name") val displayName: String? = null, 41 | val email: String? = null, 42 | val followers: Followers, 43 | val images: List? = null, 44 | val product: String? = null, 45 | @SerialName("explicit_content") val explicitContentSettings: ExplicitContentSettings? = null, 46 | val type: String 47 | ) : CoreObject() { 48 | override fun getMembersThatNeedApiInstantiation(): List = listOf(this) 49 | } 50 | 51 | /** 52 | * Public information about a Spotify user 53 | * 54 | * @param displayName The name displayed on the user’s profile. null if not available. 55 | * @param followers Information about the followers of this user. 56 | * @param href A link to the Web API endpoint for this user. 57 | * @param id The Spotify user ID for this user. 58 | * @param images The user’s profile image. 59 | * @param type The object type: “user” 60 | */ 61 | @Serializable 62 | public data class SpotifyPublicUser( 63 | @SerialName("external_urls") override val externalUrlsString: Map, 64 | override val href: String, 65 | override val id: String, 66 | override val uri: UserUri, 67 | 68 | @SerialName("display_name") val displayName: String? = null, 69 | val followers: Followers = Followers(null, -1), 70 | val images: List = listOf(), 71 | val type: String 72 | ) : CoreObject() { 73 | override fun getMembersThatNeedApiInstantiation(): List = listOf(this) 74 | } 75 | 76 | /** 77 | * Information about a Spotify user's followers 78 | * 79 | * @param href Will always be null, per the Spotify documentation, 80 | * until the Web API is updated to support this. 81 | * 82 | * @param total Null or -1 if the user object does not contain followers, otherwise the amount of followers the user has 83 | */ 84 | @Serializable(with = FollowersSerializer::class) 85 | public data class Followers( 86 | val href: String? = null, 87 | @SerialName("total") val total: Int? = null 88 | ) 89 | 90 | // custom serializer to convert total (which now is a double from spotify's response) to int, because it should be an int 91 | private object FollowersSerializer : KSerializer { 92 | override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Followers") { 93 | element("href") 94 | element("total") 95 | } 96 | 97 | override fun serialize(encoder: Encoder, value: Followers) { 98 | encoder.encodeStructure(descriptor) { 99 | encodeNullableSerializableElement(descriptor, 0, String.serializer(), value.href) 100 | encodeNullableSerializableElement(descriptor, 1, Int.serializer(), value.total) 101 | } 102 | } 103 | 104 | override fun deserialize(decoder: Decoder): Followers { 105 | return decoder.decodeStructure(descriptor) { 106 | var href: String? = null 107 | var total: Int? = null 108 | 109 | while (true) { 110 | when (val index = decodeElementIndex(descriptor)) { 111 | 0 -> href = decoder.decodeNullableSerializableValue(String.serializer()) 112 | 1 -> total = decoder.decodeNullableSerializableValue(Double.serializer())?.toInt() 113 | CompositeDecoder.DECODE_DONE -> break 114 | else -> error("Unexpected index: $index") 115 | } 116 | } 117 | 118 | Followers(href, total) 119 | } 120 | } 121 | } 122 | 123 | @Serializable 124 | public data class ExplicitContentSettings( 125 | @SerialName("filter_enabled") val filterEnabled: Boolean, 126 | @SerialName("filter_locked") val filterLocked: Boolean 127 | ) 128 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/pub/TrackApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.pub 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyException.BadRequestException 6 | import com.adamratzman.spotify.http.SpotifyEndpoint 7 | import com.adamratzman.spotify.models.AudioAnalysis 8 | import com.adamratzman.spotify.models.AudioFeatures 9 | import com.adamratzman.spotify.models.AudioFeaturesResponse 10 | import com.adamratzman.spotify.models.PlayableUri 11 | import com.adamratzman.spotify.models.Track 12 | import com.adamratzman.spotify.models.TrackList 13 | import com.adamratzman.spotify.models.serialization.toObject 14 | import com.adamratzman.spotify.utils.Market 15 | import com.adamratzman.spotify.utils.catch 16 | import com.adamratzman.spotify.utils.encodeUrl 17 | import com.adamratzman.spotify.utils.getSpotifyId 18 | 19 | /** 20 | * Endpoints for retrieving information about one or more tracks from the Spotify catalog. 21 | * 22 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/)** 23 | */ 24 | public class TrackApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) { 25 | /** 26 | * Get Spotify catalog information for a single track identified by its unique Spotify ID. 27 | * 28 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-track/)** 29 | * 30 | * @param track The id or uri for the track. 31 | * @param market Provide this parameter if you want to apply [Track Relinking](https://github.com/adamint/spotify-web-api-kotlin#track-relinking) 32 | * 33 | * @return possibly-null Track. This behavior is *the same* as in [getTracks] 34 | */ 35 | public suspend fun getTrack(track: String, market: Market? = null): Track? = catch { 36 | get( 37 | endpointBuilder("/tracks/${PlayableUri(track).id.encodeUrl()}").with( 38 | "market", 39 | market?.getSpotifyId() 40 | ).toString() 41 | ).toObject(Track.serializer(), api, json) 42 | } 43 | 44 | /** 45 | * Get Spotify catalog information for multiple tracks based on their Spotify IDs. 46 | * 47 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-several-tracks/)** 48 | * 49 | * @param tracks The id or uri for the tracks. Maximum **50**. 50 | * @param market Provide this parameter if you want to apply [Track Relinking](https://github.com/adamint/spotify-web-api-kotlin#track-relinking) 51 | * 52 | * @return List of possibly-null full [Track] objects. 53 | */ 54 | public suspend fun getTracks(vararg tracks: String, market: Market? = null): List { 55 | checkBulkRequesting(50, tracks.size) 56 | return bulkStatelessRequest(50, tracks.toList()) { chunk -> 57 | get( 58 | endpointBuilder("/tracks").with("ids", chunk.joinToString(",") { PlayableUri(it).id.encodeUrl() }) 59 | .with("market", market?.getSpotifyId()).toString() 60 | ).toObject(TrackList.serializer(), api, json).tracks 61 | }.flatten() 62 | } 63 | 64 | /** 65 | * Get a detailed audio analysis for a single track identified by its unique Spotify ID. 66 | * 67 | * The Audio Analysis endpoint provides low-level audio analysis for all of the tracks in the Spotify catalog. 68 | * The Audio Analysis describes the track’s structure and musical content, including rhythm, pitch, and timbre. 69 | * All information is precise to the audio sample. 70 | * 71 | * Many elements of analysis include confidence values, a floating-point number ranging from 0.0 to 1.0. 72 | * Confidence indicates the reliability of its corresponding attribute. Elements carrying a small confidence value 73 | * should be considered speculative. There may not be sufficient data in the audio to compute the attribute with 74 | * high certainty. 75 | * 76 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-analysis/)** 77 | * 78 | * @param track The id or uri for the track. 79 | * 80 | * @throws BadRequestException if [track] cannot be found 81 | */ 82 | public suspend fun getAudioAnalysis(track: String): AudioAnalysis = 83 | get(endpointBuilder("/audio-analysis/${PlayableUri(track).id.encodeUrl()}").toString()) 84 | .toObject(AudioAnalysis.serializer(), api, json) 85 | 86 | /** 87 | * Get audio feature information for a single track identified by its unique Spotify ID. 88 | * 89 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-features/)** 90 | * 91 | * @param track The id or uri for the track. 92 | * 93 | * @throws BadRequestException if [track] cannot be found 94 | */ 95 | public suspend fun getAudioFeatures(track: String): AudioFeatures = 96 | get(endpointBuilder("/audio-features/${PlayableUri(track).id.encodeUrl()}").toString()) 97 | .toObject(AudioFeatures.serializer(), api, json) 98 | 99 | /** 100 | * Get audio features for multiple tracks based on their Spotify IDs. 101 | * 102 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-several-audio-features/)** 103 | * 104 | * @param tracks vararg of track ids or uris. Maximum **100**. 105 | * 106 | * @return Ordered list of possibly-null [AudioFeatures] objects. 107 | */ 108 | public suspend fun getAudioFeatures(vararg tracks: String): List { 109 | checkBulkRequesting(100, tracks.size) 110 | return bulkStatelessRequest(100, tracks.toList()) { chunk -> 111 | get( 112 | endpointBuilder("/audio-features").with( 113 | "ids", 114 | chunk.joinToString(",") { PlayableUri(it).id.encodeUrl() } 115 | ).toString() 116 | ) 117 | .toObject(AudioFeaturesResponse.serializer(), api, json).audioFeatures 118 | }.flatten() 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/androidMain/kotlin/com/adamratzman/spotify/auth/implicit/SpotifyImplicitLoginActivity.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.auth.implicit 3 | 4 | import android.app.Activity 5 | import android.content.Intent 6 | import com.adamratzman.spotify.SpotifyImplicitGrantApi 7 | import com.adamratzman.spotify.SpotifyScope 8 | import com.adamratzman.spotify.auth.SpotifyDefaultCredentialStore 9 | import com.adamratzman.spotify.auth.SpotifyDefaultCredentialStore.Companion.activityBackOnImplicitAuth 10 | import com.adamratzman.spotify.models.Token 11 | import com.adamratzman.spotify.spotifyImplicitGrantApi 12 | import com.adamratzman.spotify.utils.logToConsole 13 | import com.spotify.sdk.android.auth.AuthorizationClient 14 | import com.spotify.sdk.android.auth.AuthorizationRequest 15 | import com.spotify.sdk.android.auth.AuthorizationResponse 16 | import com.spotify.sdk.android.auth.LoginActivity 17 | 18 | /** 19 | * Wrapper around spotify-auth's [LoginActivity] that allows configuration of the authentication process, along with 20 | * callbacks on successful and failed authentication. Pair this with [SpotifyDefaultCredentialStore] to easily store credentials. 21 | * To use, you must extend from either [AbstractSpotifyAppImplicitLoginActivity] or [AbstractSpotifyAppCompatImplicitLoginActivity] 22 | * 23 | * @property state The state to use to verify the login request. 24 | * @property clientId Your application's Spotify client id. 25 | * @property clientId Your application's Spotify client secret. 26 | * @property redirectUri Your application's Spotify redirect id - NOTE that this should be an android scheme (such as spotifyapp://authback) 27 | * and that this must be registered in your manifest. 28 | * @property useDefaultRedirectHandler Disable if you will not be using [useDefaultRedirectHandler] but will be setting [SpotifyDefaultImplicitAuthHelper.activityBackOnImplicitAuth]. 29 | */ 30 | public interface SpotifyImplicitLoginActivity { 31 | public val activity: Activity 32 | 33 | public val state: Int 34 | public val clientId: String 35 | public val redirectUri: String 36 | public val useDefaultRedirectHandler: Boolean 37 | 38 | /** 39 | * Return the scopes that you are going to request from the user here. 40 | */ 41 | public fun getRequestingScopes(): List 42 | 43 | /** 44 | * Override this to define what to do after authentication has been successfully completed. A valid, usable 45 | * [spotifyApi] is provided to you. You may likely want to use [SpotifyDefaultCredentialStore] to store/retrieve this token. 46 | * 47 | * @param spotifyApi Valid, usable [SpotifyImplicitGrantApi] that you can use to make requests. 48 | */ 49 | public fun onSuccess(spotifyApi: SpotifyImplicitGrantApi) 50 | 51 | /** 52 | * Override this to define what to do after authentication has failed. You may want to use [SpotifyDefaultCredentialStore] to remove any stored token. 53 | */ 54 | public fun onFailure(errorMessage: String) 55 | 56 | /** 57 | * Override this to define what to do after [onSuccess] has run. 58 | * The default behavior is to finish the activity, and redirect the user back to the activity set on [SpotifyDefaultCredentialStore.activityBackOnImplicitAuth] 59 | * only if [guardValidImplicitSpotifyApi] has been used or if [SpotifyDefaultCredentialStore.activityBackOnImplicitAuth] has been set. 60 | */ 61 | public fun redirectAfterOnSuccessAuthentication() { 62 | if (useDefaultRedirectHandler && activityBackOnImplicitAuth != null) { 63 | activity.startActivity(Intent(activity, activityBackOnImplicitAuth)) 64 | activityBackOnImplicitAuth = null 65 | } 66 | activity.finish() 67 | } 68 | 69 | /** 70 | * Trigger the actual spotify-auth login activity to authenticate the user. 71 | */ 72 | public fun triggerLoginActivity() { 73 | val authorizationRequest = AuthorizationRequest.Builder(clientId, AuthorizationResponse.Type.TOKEN, redirectUri) 74 | .setScopes(getRequestingScopes().map { it.uri }.toTypedArray()) 75 | .setState(state.toString()) 76 | .build() 77 | logToConsole("Triggering spotify-auth login for url ${authorizationRequest.toUri().path}") 78 | AuthorizationClient.openLoginActivity(activity, state, authorizationRequest) 79 | } 80 | 81 | /** 82 | * Processes the result of [LoginActivity], invokes callbacks, then finishes. 83 | */ 84 | public fun processActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { 85 | if (requestCode == state) { 86 | val response = AuthorizationClient.getResponse(resultCode, intent) 87 | logToConsole("Got implicit auth response of ${response.type}") 88 | when { 89 | response.type == AuthorizationResponse.Type.TOKEN -> { 90 | val token = Token( 91 | response.accessToken, 92 | response.type.name, 93 | response.expiresIn 94 | ) 95 | val api = spotifyImplicitGrantApi( 96 | clientId = clientId, 97 | token = token 98 | ) 99 | logToConsole("Built implicit grant api. Executing success handler..") 100 | onSuccess(api) 101 | redirectAfterOnSuccessAuthentication() 102 | } 103 | // AuthorizationResponse.Type.CODE -> TODO() 104 | // AuthorizationResponse.Type.UNKNOWN -> TODO() 105 | response.type == AuthorizationResponse.Type.ERROR -> { 106 | logToConsole("Got error in authorization... executing error handler") 107 | onFailure(response.error ?: "Generic authentication error") 108 | } 109 | response.type == AuthorizationResponse.Type.EMPTY -> { 110 | logToConsole("Got empty authorization... executing error handler") 111 | onFailure(response.error ?: "Authentication empty") 112 | } 113 | } 114 | activity.finish() 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/androidMain/kotlin/com/adamratzman/spotify/notifications/AbstractSpotifyBroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.notifications 3 | 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import com.adamratzman.spotify.models.PlayableUri 8 | import com.adamratzman.spotify.notifications.AbstractSpotifyBroadcastReceiver.Companion.BaseSpotifyNotificationId 9 | import com.adamratzman.spotify.utils.logToConsole 10 | 11 | /** 12 | * If you are developing an Android application and want to know what is happening in the Spotify app, 13 | * you can subscribe to broadcast notifications from it. The Spotify app can posts sticky media broadcast notifications 14 | * that can be read by any app on the same Android device. The media notifications contain information about what is 15 | * currently being played in the Spotify App, as well as the playback position and the playback status of the app. 16 | * 17 | * Note that media notifications need to be enabled manually in the Spotify app 18 | * 19 | * You need to extend this class and register it, whether through the manifest or fragment/activity to receive notifications, as 20 | * well as overriding [onPlaybackStateChanged], [onQueueChanged], and/or [onMetadataChanged]. 21 | * 22 | */ 23 | public abstract class AbstractSpotifyBroadcastReceiver : BroadcastReceiver() { 24 | override fun onReceive(context: Context, intent: Intent) { 25 | val timeSentInMs = intent.getLongExtra("timeSent", 0L) 26 | 27 | when (intent.action) { 28 | SpotifyBroadcastType.PlaybackStateChanged.id -> onPlaybackStateChanged( 29 | SpotifyPlaybackStateChangedData( 30 | intent.getBooleanExtra("playing", false), 31 | intent.getIntExtra("playbackPosition", 0), 32 | timeSentInMs 33 | ) 34 | ) 35 | SpotifyBroadcastType.QueueChanged.id -> onQueueChanged(SpotifyQueueChangedData(timeSentInMs)) 36 | SpotifyBroadcastType.MetadataChanged.id -> onMetadataChanged( 37 | SpotifyMetadataChangedData( 38 | PlayableUri(intent.getStringExtra("id")!!), 39 | intent.getStringExtra("artist")!!, 40 | intent.getStringExtra("album")!!, 41 | intent.getStringExtra("track")!!, 42 | intent.getIntExtra("length", 0), 43 | timeSentInMs 44 | ) 45 | ) 46 | } 47 | } 48 | 49 | /** 50 | * A metadata change intent is sent when a new track starts playing. 51 | * 52 | * @param data The data associated with this broadcast. 53 | */ 54 | public open fun onMetadataChanged(data: SpotifyMetadataChangedData) { 55 | sendUnregisteredNotificationMessage(data.type.id) 56 | } 57 | 58 | /** 59 | * A playback state change is sent whenever the user presses play/pause, or when seeking the track position. 60 | * 61 | * @param data The data associated with this broadcast. 62 | */ 63 | public open fun onPlaybackStateChanged(data: SpotifyPlaybackStateChangedData) { 64 | sendUnregisteredNotificationMessage(data.type.id) 65 | } 66 | 67 | /** 68 | * A queue change is sent whenever the play queue is changed. 69 | * 70 | * @param data The data associated with this broadcast. 71 | */ 72 | public open fun onQueueChanged(data: SpotifyQueueChangedData) { 73 | sendUnregisteredNotificationMessage(data.type.id) 74 | } 75 | 76 | private fun sendUnregisteredNotificationMessage(action: String) { 77 | logToConsole("Unregistered notification $action has no handler.") 78 | } 79 | 80 | public companion object { 81 | public const val BaseSpotifyNotificationId: String = "com.spotify.music" 82 | } 83 | } 84 | 85 | /** 86 | * Broadcast receiver types. These must be turned on manually in the Spotify app settings. 87 | */ 88 | public enum class SpotifyBroadcastType(public val id: String) { 89 | PlaybackStateChanged("$BaseSpotifyNotificationId.playbackstatechanged"), 90 | QueueChanged("$BaseSpotifyNotificationId.queuechanged"), 91 | MetadataChanged("$BaseSpotifyNotificationId.metadatachanged") 92 | } 93 | 94 | /** 95 | * Data from a broadcast event 96 | * 97 | * @param type The type of the broadcast event 98 | */ 99 | public abstract class SpotifyBroadcastEventData(public val type: SpotifyBroadcastType) 100 | 101 | /** 102 | * A metadata change intent is sent when a new track starts playing. It uses the intent action com.spotify.music.metadatachanged. 103 | * 104 | * @param playableUri A Spotify URI for the track or playable. 105 | * @param artistName The track artist. 106 | * @param albumName The album name. 107 | * @param trackName The track name. 108 | * @param trackLengthInSec Length of the track, in seconds. 109 | * @param timeSentInMs When the notification was sent. 110 | */ 111 | public data class SpotifyMetadataChangedData( 112 | val playableUri: PlayableUri, 113 | val artistName: String, 114 | val albumName: String, 115 | val trackName: String, 116 | val trackLengthInSec: Int, 117 | val timeSentInMs: Long 118 | ) : SpotifyBroadcastEventData(SpotifyBroadcastType.MetadataChanged) 119 | 120 | /** 121 | * A playback state change is sent whenever the user presses play/pause, or when seeking the track position. It uses the intent action com.spotify.music.playbackstatechanged. 122 | * 123 | * @param playing True if playing, false if paused. 124 | * @param positionInMs The current playback position in milliseconds. 125 | * @param timeSentInMs When the notification was sent. 126 | */ 127 | public data class SpotifyPlaybackStateChangedData( 128 | val playing: Boolean, 129 | val positionInMs: Int, 130 | val timeSentInMs: Long 131 | ) : SpotifyBroadcastEventData(SpotifyBroadcastType.PlaybackStateChanged) 132 | 133 | /** 134 | * A queue change is sent whenever the play queue is changed. It uses the intent action com.spotify.music.queuechanged. 135 | * 136 | * @param timeSentInMs When the notification was sent. 137 | */ 138 | public class SpotifyQueueChangedData( 139 | public val timeSentInMs: Long 140 | ) : SpotifyBroadcastEventData(SpotifyBroadcastType.QueueChanged) 141 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com.adamratzman.spotify/endpoints/pub/ShowApi.kt: -------------------------------------------------------------------------------- 1 | /* Spotify Web API, Kotlin Wrapper; MIT License, 2017-2022; Original author: Adam Ratzman */ 2 | package com.adamratzman.spotify.endpoints.pub 3 | 4 | import com.adamratzman.spotify.GenericSpotifyApi 5 | import com.adamratzman.spotify.SpotifyAppApi 6 | import com.adamratzman.spotify.SpotifyException.BadRequestException 7 | import com.adamratzman.spotify.SpotifyScope 8 | import com.adamratzman.spotify.http.SpotifyEndpoint 9 | import com.adamratzman.spotify.models.PagingObject 10 | import com.adamratzman.spotify.models.Show 11 | import com.adamratzman.spotify.models.ShowList 12 | import com.adamratzman.spotify.models.ShowUri 13 | import com.adamratzman.spotify.models.SimpleEpisode 14 | import com.adamratzman.spotify.models.SimpleShow 15 | import com.adamratzman.spotify.models.serialization.toNonNullablePagingObject 16 | import com.adamratzman.spotify.models.serialization.toObject 17 | import com.adamratzman.spotify.utils.Market 18 | import com.adamratzman.spotify.utils.catch 19 | import com.adamratzman.spotify.utils.encodeUrl 20 | import com.adamratzman.spotify.utils.getSpotifyId 21 | 22 | /** 23 | * Endpoints for retrieving information about one or more shows and their episodes from the Spotify catalog. 24 | * 25 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/shows/)** 26 | */ 27 | public open class ShowApi(api: GenericSpotifyApi) : SpotifyEndpoint(api) { 28 | /** 29 | * Get Spotify catalog information for a single show identified by its unique Spotify ID. 30 | * 31 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 32 | * 33 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/tracks/get-track/)** 34 | * 35 | * @param id The Spotify ID for the show. 36 | * @param market If a country code is specified, only shows and episodes that are available in that market will be returned. 37 | * If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. 38 | * Note: If neither market or user country are provided, the content is considered unavailable for the client. 39 | * Users can view the country that is associated with their account in the account settings. Required for [SpotifyAppApi], but **you may use [Market.FROM_TOKEN] to get the user market** 40 | * 41 | * @return possibly-null Show. This behavior is *not the same* as in [getShows] 42 | */ 43 | public suspend fun getShow(id: String, market: Market): Show? { 44 | return catch { 45 | get( 46 | endpointBuilder("/shows/${ShowUri(id).id.encodeUrl()}").with("market", market.getSpotifyId()).toString() 47 | ).toObject(Show.serializer(), api, json) 48 | } 49 | } 50 | 51 | /** 52 | * Get Spotify catalog information for multiple shows based on their Spotify IDs. 53 | * 54 | * **Invalid show ids will result in a [BadRequestException] 55 | * 56 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 57 | * 58 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/shows/get-several-shows/)** 59 | * 60 | * @param ids The id or uri for the shows. Maximum **50**. 61 | * @param market If a country code is specified, only shows and episodes that are available in that market will be returned. 62 | * If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. 63 | * Note: If neither market or user country are provided, the content is considered unavailable for the client. 64 | * Users can view the country that is associated with their account in the account settings. Required for [SpotifyAppApi], but **you may use [Market.FROM_TOKEN] to get the user market** 65 | * 66 | * @return List of possibly-null [SimpleShow] objects, if the show was not found or invalid ids were provided. 67 | */ 68 | public suspend fun getShows(vararg ids: String, market: Market): List { 69 | checkBulkRequesting(50, ids.size) 70 | return bulkStatelessRequest(50, ids.toList()) { chunk -> 71 | get( 72 | endpointBuilder("/shows").with("ids", chunk.joinToString(",") { ShowUri(it).id.encodeUrl() }) 73 | .with("market", market.getSpotifyId()).toString() 74 | ).toObject(ShowList.serializer(), api, json).shows 75 | }.flatten() 76 | } 77 | 78 | /** 79 | * Get Spotify catalog information about an show’s episodes. 80 | * 81 | * **Reading the user’s resume points on episode objects requires the [SpotifyScope.UserReadPlaybackPosition] scope** 82 | * 83 | * **[Api Reference](https://developer.spotify.com/documentation/web-api/reference/shows/get-shows-episodes/)** 84 | * 85 | * @param id The Spotify ID for the show. 86 | * @param market If a country code is specified, only shows and episodes that are available in that market will be returned. 87 | * If a valid user access token is specified in the request header, the country associated with the user account will take priority over this parameter. 88 | * Note: If neither market or user country are provided, the content is considered unavailable for the client. 89 | * Users can view the country that is associated with their account in the account settings. Required for [SpotifyAppApi], but **you may use [Market.FROM_TOKEN] to get the user market** 90 | * @param limit The number of objects to return. Default: 20 (or api limit). Minimum: 1. Maximum: 50. 91 | * @param offset The index of the first item to return. Default: 0. Use with limit to get the next set of items 92 | * 93 | * @throws BadRequestException if the playlist cannot be found 94 | */ 95 | public suspend fun getShowEpisodes( 96 | id: String, 97 | limit: Int? = null, 98 | offset: Int? = null, 99 | market: Market 100 | ): PagingObject = get( 101 | endpointBuilder("/shows/${ShowUri(id).id.encodeUrl()}/episodes").with("limit", limit) 102 | .with("offset", offset).with("market", market.getSpotifyId()).toString() 103 | ).toNonNullablePagingObject(SimpleEpisode.serializer(), null, api, json) 104 | } 105 | --------------------------------------------------------------------------------