From 40bd4ba0f166a510ab94051bb3f12be4d75c774f Mon Sep 17 00:00:00 2001 From: "moritz.schulze" Date: Fri, 17 Mar 2017 15:00:46 +0100 Subject: [PATCH] Initial commit for the Soundcloud Alexa Skill Open Source version, squashed into one commit. --- .gitignore | 3 + build.gradle | 42 +++ proxy-config/index.html | 83 +++++ proxy-config/nginx.conf | 49 +++ settings.gradle | 2 + speechAssets/IntentSchema_en_US.json | 52 +++ speechAssets/SampleUtterances_de_DE.txt | 28 ++ speechAssets/SampleUtterances_en_UK.txt | 25 ++ speechAssets/SampleUtterances_en_US.txt | 25 ++ .../de/techdev/alexa/soundcloud/Model.kt | 146 +++++++++ .../alexa/soundcloud/SoundcloudAccessor.kt | 166 ++++++++++ .../alexa/soundcloud/SoundcloudSessions.kt | 207 ++++++++++++ .../SoundcloudSpeechletStreamHandler.kt | 310 ++++++++++++++++++ .../de/techdev/alexa/soundcloud/Translator.kt | 21 ++ src/main/resources/log4j.properties | 8 + src/main/resources/messages_de_DE.properties | 46 +++ src/main/resources/messages_en_UK.properties | 46 +++ src/main/resources/messages_en_US.properties | 46 +++ 18 files changed, 1305 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 proxy-config/index.html create mode 100644 proxy-config/nginx.conf create mode 100644 settings.gradle create mode 100644 speechAssets/IntentSchema_en_US.json create mode 100644 speechAssets/SampleUtterances_de_DE.txt create mode 100644 speechAssets/SampleUtterances_en_UK.txt create mode 100644 speechAssets/SampleUtterances_en_US.txt create mode 100644 src/main/kotlin/de/techdev/alexa/soundcloud/Model.kt create mode 100644 src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudAccessor.kt create mode 100644 src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudSessions.kt create mode 100644 src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudSpeechletStreamHandler.kt create mode 100644 src/main/kotlin/de/techdev/alexa/soundcloud/Translator.kt create mode 100644 src/main/resources/log4j.properties create mode 100644 src/main/resources/messages_de_DE.properties create mode 100644 src/main/resources/messages_en_UK.properties create mode 100644 src/main/resources/messages_en_US.properties diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1886ba5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/build/ +.idea/ +.gradle/ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..f5cb43e --- /dev/null +++ b/build.gradle @@ -0,0 +1,42 @@ +buildscript { + ext.kotlin_version = '1.1.2' + + repositories { + mavenCentral() + } + dependencies { + classpath group: 'org.jetbrains.kotlin', name: 'kotlin-gradle-plugin', version: "$kotlin_version" + } +} + +group 'de.techdev.alexa.soundcloud' +version '1.0.0-RELEASE' + +apply plugin: 'kotlin' + +sourceCompatibility = 1.8 + +repositories { + mavenCentral() +} + +dependencies { + compile group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jre8', version: "$kotlin_version" + + compile group: 'com.amazon.alexa', name: 'alexa-skills-kit', version: '1.3.0' + compile group: 'com.amazonaws', name: 'aws-lambda-java-core', version: '1.1.0' + runtime group: 'com.amazonaws', name: 'aws-lambda-java-log4j', version: '1.0.0' + + compile group: 'com.amazonaws', name: 'aws-java-sdk-dynamodb', version: '1.11.125' + + compile group: 'com.google.code.gson', name: 'gson', version: '2.8.0' + compile group: 'com.squareup.okhttp3', name: 'okhttp', version: '3.7.0' +} + +task fatJar(type: Jar) { + baseName = project.name + '-fat' + from { configurations.runtime.collect { it.isDirectory() ? it : zipTree(it) } } + with jar +} + +build.dependsOn fatJar \ No newline at end of file diff --git a/proxy-config/index.html b/proxy-config/index.html new file mode 100644 index 0000000..956ea69 --- /dev/null +++ b/proxy-config/index.html @@ -0,0 +1,83 @@ + + + + + + + Alexa Cloud Music for SoundCloud by techdev + + + + + + + +
+
+
+

+ Techdev logo
+ Alexa Cloud Music for SoundCloud +

+

Deutsch

+

+ Der Alexa Cloud Music Skill wird bereitgestellt von techdev Solutions GmbH. + Um ihn zu nutzen müssen Sie ihn mit SoundCloud verknüpfen. +

+

+ Bitte beachten Sie, dass eine Verknüpfung derzeit nicht möglich ist wenn Sie Google nutzen um sich bei SoundCloud einzuloggen. +

+ Sie können unsere Datenschutzbestimmungen hier einsehen. +

+ +
+ +
+

English

+

+ The Alexa Cloud Music skill is provided by techdev Solutions GmbH. To use it + you have to connect it with SoundCloud. +

+

+ Please be aware that connecting to SoundCloud is currently not possible if you use Google to log in to SoundCloud. +

+

+ You can read our privacy policy here. +

+ +
+ +
+ +
+
+
+
+ +
+
+ + diff --git a/proxy-config/nginx.conf b/proxy-config/nginx.conf new file mode 100644 index 0000000..c2fb572 --- /dev/null +++ b/proxy-config/nginx.conf @@ -0,0 +1,49 @@ +server { + server { + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2 default_server; + + error_log /var/log/nginx/error.log notice; + + ssl_certificate /PATH/TO/YOUR/CERTIFICATE; + ssl_certificate_key /PATH/TO/YOUR/PRIVATE_KEY; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_prefer_server_ciphers on; + ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH"; + ssl_ecdh_curve secp384r1; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 8.8.4.4 valid=300s; + resolver_timeout 5s; + add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + + ssl_dhparam /etc/ssl/certs/dhparam.pem; + + server_name YOUR_SERVER_NAME.SOME.DOMAIN; + + location /sc/connect/do { + set $args client_id=$arg_client_id&response_type=$arg_response_type&state=$arg_state&redirect_uri=ENTER_YOUR_REDIRECT_URI_YOU_CONFIGURED_WITH_SOUNDCLOUD + rewrite ^.+$ https://soundcloud.com/connect redirect; + } + + location /sc/connect { + # Display the landing page required by Amazon. + alias /var/www/alexa-soundcloud; + } + + # Required by Let's Encrypt + location ~/.well-known { + allow all; + } + + location / { + return 404; + } + } + +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..b39b7dd --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'alexa-soundcloud-skill' + diff --git a/speechAssets/IntentSchema_en_US.json b/speechAssets/IntentSchema_en_US.json new file mode 100644 index 0000000..5bfaba1 --- /dev/null +++ b/speechAssets/IntentSchema_en_US.json @@ -0,0 +1,52 @@ +{ + "intents": [ + { + "intent": "PlayMyFavoritesIntent" + }, + { + "intent": "PlayMyStreamIntent" + }, + { + "intent": "LikeTrackIntent" + }, + { + "intent": "TellCurrentTrackIntent" + }, + { + "intent": "FollowUserIntent" + }, + { + "intent": "AMAZON.HelpIntent" + }, + { + "intent": "AMAZON.PauseIntent" + }, + { + "intent": "AMAZON.CancelIntent" + }, + { + "intent": "AMAZON.StopIntent" + }, + { + "intent": "AMAZON.ResumeIntent" + }, + { + "intent": "AMAZON.NextIntent" + }, + { + "intent": "AMAZON.PreviousIntent" + }, + { + "intent": "AMAZON.LoopOnIntent" + }, + { + "intent": "AMAZON.LoopOffIntent" + }, + { + "intent": "AMAZON.ShuffleOnIntent" + }, + { + "intent": "AMAZON.ShuffleOffIntent" + } + ] +} \ No newline at end of file diff --git a/speechAssets/SampleUtterances_de_DE.txt b/speechAssets/SampleUtterances_de_DE.txt new file mode 100644 index 0000000..579bd38 --- /dev/null +++ b/speechAssets/SampleUtterances_de_DE.txt @@ -0,0 +1,28 @@ +PlayMyFavoritesIntent Spiele meine Favoriten +PlayMyFavoritesIntent Meine Favoriten + +PlayMyStreamIntent Spiele meinen Stream +PlayMyStreamIntent Meinen Stream +PlayMyStreamIntent Spiele Musik +PlayMyStreamIntent Spiele neue Musik + +LikeTrackIntent Like dieses Lied +LikeTrackIntent Like diesen Song +LikeTrackIntent Like diesen Track +LikeTrackIntent Like this +LikeTrackIntent Mir gefällt das +LikeTrackIntent Mir gefällt das Lied +LikeTrackIntent Mir gefällt der Song +LikeTrackIntent Mir gefällt der Track + +FollowUserIntent Folge dem User + +TellCurrentTrackIntent Was gerade läuft +TellCurrentTrackIntent Was läuft gerade +TellCurrentTrackIntent Welcher Song ist das +TellCurrentTrackIntent Welcher Track ist das +TellCurrentTrackIntent Welches Lied ist das +TellCurrentTrackIntent Which track is that +TellCurrentTrackIntent Welcher Track läuft gerade +TellCurrentTrackIntent Welcher Song läuft gerade +TellCurrentTrackIntent Welches Lied läuft gerade \ No newline at end of file diff --git a/speechAssets/SampleUtterances_en_UK.txt b/speechAssets/SampleUtterances_en_UK.txt new file mode 100644 index 0000000..ff68097 --- /dev/null +++ b/speechAssets/SampleUtterances_en_UK.txt @@ -0,0 +1,25 @@ +PlayMyFavoritesIntent Play my favourites +PlayMyFavoritesIntent My favourites + +PlayMyStreamIntent Play my stream +PlayMyStreamIntent My stream +PlayMyStreamIntent Play some music +PlayMyStreamIntent Play music +PlayMyStreamIntent Play new music + +LikeTrackIntent Like this track +LikeTrackIntent Like this song +LikeTrackIntent Like this + +FollowUserIntent Follow the user + +TellCurrentTrackIntent What is currently playing +TellCurrentTrackIntent What track is this +TellCurrentTrackIntent What song is this +TellCurrentTrackIntent What track is playing +TellCurrentTrackIntent What song is playing +TellCurrentTrackIntent What is playing +TellCurrentTrackIntent Which track is that +TellCurrentTrackIntent Which song is that +TellCurrentTrackIntent Which track is playing +TellCurrentTrackIntent Which song is playing \ No newline at end of file diff --git a/speechAssets/SampleUtterances_en_US.txt b/speechAssets/SampleUtterances_en_US.txt new file mode 100644 index 0000000..8e5b663 --- /dev/null +++ b/speechAssets/SampleUtterances_en_US.txt @@ -0,0 +1,25 @@ +PlayMyFavoritesIntent Play my favorites +PlayMyFavoritesIntent My favorites + +PlayMyStreamIntent Play my stream +PlayMyStreamIntent My stream +PlayMyStreamIntent Play some music +PlayMyStreamIntent Play music +PlayMyStreamIntent Play new music + +LikeTrackIntent Like this track +LikeTrackIntent Like this song +LikeTrackIntent Like this + +FollowUserIntent Follow the user + +TellCurrentTrackIntent What is currently playing +TellCurrentTrackIntent What track is this +TellCurrentTrackIntent What song is this +TellCurrentTrackIntent What track is playing +TellCurrentTrackIntent What song is playing +TellCurrentTrackIntent What is playing +TellCurrentTrackIntent Which track is that +TellCurrentTrackIntent Which song is that +TellCurrentTrackIntent Which track is playing +TellCurrentTrackIntent Which song is playing \ No newline at end of file diff --git a/src/main/kotlin/de/techdev/alexa/soundcloud/Model.kt b/src/main/kotlin/de/techdev/alexa/soundcloud/Model.kt new file mode 100644 index 0000000..4e40d2e --- /dev/null +++ b/src/main/kotlin/de/techdev/alexa/soundcloud/Model.kt @@ -0,0 +1,146 @@ +package de.techdev.alexa.soundcloud + +import java.net.URL +import java.time.Duration +import java.time.ZonedDateTime + +data class ListResult(val collection: List, val nextHref: URL?) + +data class ActivityStream( + val collection: List>, + val nextHref: URL?, + val futureHref: URL +) { + /** + * Returns only elements that are tracks. + */ + fun tracks(): List = collection + .filter { it.type == ActivityElementType.track_repost || it.type == ActivityElementType.track } + .map { it.origin as Track } +} + +enum class ActivityElementType { + playlist, track, playlist_repost, track_repost +} + +data class ActivityElement( + val origin: T, + val tags: String?, + val createdAt: ZonedDateTime, + val type: ActivityElementType +) + +/** + * Just a marker interface for items from /me/activities/tracks/affiliated (can be playlists and tracks) + */ +interface ActivityOrigin + +data class Playlist( + val duration: Duration, + val releaseDay: String?, + val permalinkUrl: URL, + val repostsCount: Long, + val genre: String, + val permalink: String, + val purchaseUrl: URL?, + val releaseMonth: Short?, + val description: String?, + val uri: URL, + val labelName: String?, + val tagList: String, + val releaseYear: Int?, + val secretUri: URL, + val trackCount: Int, + val userId: Long, + val lastModified: ZonedDateTime, + val license: License, + val playlistType: String?, + val tracksUri: URL, + val downloadable: Boolean?, + val sharing: String, + val secretToken: String, + val createdAt: ZonedDateTime, + val release: String?, + val likesCount: Long, + val kind: String, + val title: String, + val type: String?, + val purchaseTitle: String?, + val createdWith: String?, + val artworkUrl: URL?, + val ean: String?, + val streamable: Boolean, + val user: User, + val embeddableBy: String +) : ActivityOrigin + +data class Track( + val id: String, + val createdAt: ZonedDateTime, + val lastModified: ZonedDateTime, + val permalink: String, + val permalinkUrl: URL, + val title: String, + val duration: Duration, + val sharing: String, + val waveformUrl: URL, + val streamUrl: URL, + val uri: URL, + val userId: Long, + val artworkUrl: URL?, + val commentCount: Long, + val commentable: Boolean, + val description: String, + val downloadCount: Long, + val downloadable: Boolean, + val embeddableBy: String, + val favoritingsCount: Long, + val genre: String, + val isrc: String, + val labelId: String?, + val labelName: String, + val license: License, + val originalContentSize: Long, + val originalFormat: String, + val playbackCount: Long, + val purchaseTitle: String, + val purchaseUrl: URL, + val release: String, + val releaseDay: Short, + val releaseMonth: Short, + val releaseYear: Int, + val repostsCount: Long, + val state: String, + val streamable: Boolean, + val tagList: String, + val trackType: String, + val user: User, + val likesCount: Long, + val attachmentsUri: String, + val bpm: Short?, + val keySignature: String +) : ActivityOrigin { + fun displayImageUrl(): String? = (artworkUrl ?: user.avatarUrl)?.toExternalForm() +} + +enum class License { + NO_RIGHTS_RESERVED, + ALL_RIGHTS_RESERVED, + CC_BY, + CC_BY_NC, + CC_BY_ND, + CC_BY_SA, + CC_BY_NC_ND, + CC_BY_NC_SA +} + +data class User( + val avatarUrl: URL?, + val id: Long, + val kind: String, + val permalinkUrl: URL, + val uri: String, + val username: String, + val permalink: String, + val lastModified: ZonedDateTime +) \ No newline at end of file diff --git a/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudAccessor.kt b/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudAccessor.kt new file mode 100644 index 0000000..89fed4c --- /dev/null +++ b/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudAccessor.kt @@ -0,0 +1,166 @@ +package de.techdev.alexa.soundcloud + +import com.google.gson.FieldNamingPolicy +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonDeserializer +import com.google.gson.reflect.TypeToken +import okhttp3.* +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.InputStreamReader +import java.net.URL +import java.time.Duration +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.* + +class SoundcloudAccessor { + + private val SOUNDCLOUD_CLIENT_ID = System.getenv("SOUNDCLOUD_CLIENT_ID") ?: "ENTER_YOUR_SOUNDCLOUND_CLIENT_ID" + + private val logger = LoggerFactory.getLogger(SoundcloudAccessor::class.java) + private val client = OkHttpClient().newBuilder().followRedirects(false).build() + private val gson: Gson + + init { + gson = GsonBuilder() + .setFieldNamingStrategy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .registerTypeAdapter(ZonedDateTime::class.java, + JsonDeserializer { json, _, _ -> ZonedDateTime.parse(json.asString, DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss Z")) }) + .registerTypeAdapter(Duration::class.java, + JsonDeserializer { json, _, _ -> Duration.ofMillis(json.asLong) }) + .registerTypeAdapter(License::class.java, + JsonDeserializer { json, _, _ -> + when (json.asString) { + "no-rights-reserved" -> License.NO_RIGHTS_RESERVED + "all-rights-reserved" -> License.ALL_RIGHTS_RESERVED + "cc-by" -> License.CC_BY + "cc-by-nc" -> License.CC_BY_NC + "cc-by-nd" -> License.CC_BY_ND + "cc-by-sa" -> License.CC_BY_SA + "cc-by-nc-nd" -> License.CC_BY_NC_ND + "cc-by-nc-sa" -> License.CC_BY_NC_SA + else -> null + } + }) + .registerTypeAdapter(ActivityElement::class.java, JsonDeserializer { json, _, ctx -> + val jsonObject = json.asJsonObject + val activityType: ActivityElementType + try { + activityType = ActivityElementType.valueOf(jsonObject["type"].asString.replace('-', '_')) + } catch(e: Exception) { + logger.warn("No activity of type {} registered.", jsonObject["type"].asString) + return@JsonDeserializer null + } + val originJson = jsonObject["origin"] + val origin = when (activityType) { + ActivityElementType.playlist -> ctx.deserialize(originJson, Playlist::class.java) + ActivityElementType.playlist_repost -> ctx.deserialize(originJson, Playlist::class.java) + ActivityElementType.track -> ctx.deserialize(originJson, Track::class.java) + ActivityElementType.track_repost -> ctx.deserialize(originJson, Track::class.java) + } + val tags = if (jsonObject["tags"].isJsonNull) null else jsonObject["tags"].asString + ActivityElement(origin, tags, ctx.deserialize(jsonObject["created_at"], ZonedDateTime::class.java), activityType) + }) + .create() + } + + fun searchTracks(url: URL): ListResult { + return listResult(HttpUrl.get(url)) + } + + fun track(url: URL): Track { + val httpUrl = HttpUrl.get(url).newBuilder().addQueryParameter("client_id", SOUNDCLOUD_CLIENT_ID).build() + val request = Request.Builder().url(httpUrl).build() + val response = client.newCall(request).execute() + val track = gson.fromJson(InputStreamReader(response.body().byteStream()), Track::class.java) + response.close() + return track + } + + private fun listResult(url: HttpUrl): ListResult { + val request = Request.Builder().url(url).get().build() + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + throw IllegalStateException(e) + } + + val tracks = gson.fromJson>(InputStreamReader(response.body().byteStream()), TypeToken.getParameterized(ListResult::class.java, Track::class.java).type) + response.close() + return tracks + } + + fun convertTrackStreamUrl(publicStreamUrl: URL): URL { + val url = HttpUrl.get(publicStreamUrl).newBuilder().addQueryParameter("client_id", SOUNDCLOUD_CLIENT_ID).build() + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + val redirectLocation = response.header("Location") + // TODO some tracks might not be streamable, (e.g. test with Korn), do they return 401 here? + response.close() + return URL(redirectLocation) + } + + fun getFavorites(accessToken: String): ListResult { + val url = HttpUrl.parse("https://api.soundcloud.com/me/favorites").newBuilder() + .addQueryParameter("linked_partitioning", "true") + .addQueryParameter("oauth_token", accessToken).build() + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + val tracks = gson.fromJson>(InputStreamReader(response.body().byteStream()), TypeToken.getParameterized(ListResult::class.java, Track::class.java).type) + response.close() + return tracks + } + + fun likeTrack(accessToken: String, track: Track) { + val url = HttpUrl.parse("https://api.soundcloud.com/me/favorites/${track.id}").newBuilder() + .addQueryParameter("oauth_token", accessToken).build() + val request = Request.Builder().put(RequestBody.create(MediaType.parse("application/json"), "")).url(url).build() + val response = client.newCall(request).execute() + response.close() + } + + /** + * Follow the user. Returns an empty optional if the user is already being followed. + */ + fun follow(accessToken: String, user: User): Optional { + val url = HttpUrl.parse("https://api.soundcloud.com/me/followings/${user.id}").newBuilder() + .addQueryParameter("oauth_token", accessToken).build() + val alreadyFollowingRequest = Request.Builder().url(url).get().build() + val alreadyFollowingResponse = client.newCall(alreadyFollowingRequest).execute() + + if (alreadyFollowingResponse.code() == 404) { + val request = Request.Builder().url(url).put(RequestBody.create(MediaType.parse("application/json"), "")).build() + val response = client.newCall(request).execute() + response.close() + return Optional.of("Ok") + } + alreadyFollowingResponse.close() + return Optional.empty() + } + + /** + * Loads the activity stream for the given user + */ + fun getActivityStream(accessToken: String): ActivityStream { + val url = HttpUrl.parse("https://api.soundcloud.com/me/activities/tracks/affiliated").newBuilder() + .addQueryParameter("oauth_token", accessToken).build() + return activityStream(url) + } + + /** + * Continue the stream (by executing the call to the next_href URL) + */ + fun continueStream(soundCloudNextHref: URL, accessToken: String): ActivityStream = + activityStream(HttpUrl.get(soundCloudNextHref).newBuilder().addQueryParameter("oauth_token", accessToken).build()) + + private fun activityStream(url: HttpUrl): ActivityStream { + val request = Request.Builder().get().url(url).build() + val response = client.newCall(request).execute() + val stream = gson.fromJson(InputStreamReader(response.body().byteStream()), ActivityStream::class.java) + response.close() + return stream.copy(collection = stream.collection.filterNotNull()) + } +} diff --git a/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudSessions.kt b/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudSessions.kt new file mode 100644 index 0000000..358eaf1 --- /dev/null +++ b/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudSessions.kt @@ -0,0 +1,207 @@ +package de.techdev.alexa.soundcloud + +import com.amazonaws.regions.Regions +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder +import com.amazonaws.services.dynamodbv2.document.DynamoDB +import com.amazonaws.services.dynamodbv2.document.Item +import com.amazonaws.services.dynamodbv2.document.Table +import com.amazonaws.services.dynamodbv2.document.spec.UpdateItemSpec +import com.amazonaws.services.dynamodbv2.document.utils.ValueMap +import java.net.URL +import java.util.* + +enum class PlaybackType { + /** + * Playback e.g. by searching for an artist or using the favorites + */ + track_list, + /** + * Playback by using the user's stream + */ + stream +} + +class Playback( + /** + * List of API URLs for the tracks in the playlist + */ + val trackList: List, + /** + * URL to load the next tracks from if trackList is at the end. + */ + val soundCloudNextHref: URL?, + /** + * The current track that is playing + */ + val position: Int, + val offsetInMilliseconds: Long = 0, + val looping: Boolean = false, + val shuffle: Boolean = false, + val type: PlaybackType +) { + + /** + * Return the URL for the next track. Null if no track is available to play next. The caller could still try to + * use nextTracksUrl to load more tracks! + */ + fun nextTrackUrl(): Optional { + if (position < trackList.size - 1) { + return Optional.of(trackList[position + 1]) + } else if (position == trackList.size - 1 && looping && soundCloudNextHref == null) { + return Optional.of(trackList[0]) + } else { + return Optional.empty() + } + } + + fun previousTrackUrl(): Optional { + if (position > 0) { + return Optional.of(trackList[position - 1]) + } else { + return Optional.empty() + } + // TODO what about looping - do we want "back" to jump over 0 to the last track? Super hard with pagination! + } +} + +class UserHasNoPlaybackException : Exception() + +class SoundcloudSessions(private val accessor: SoundcloudAccessor) { + + private val table: Table + + init { + val regionName = System.getenv("AWS_DEFAULT_REGION") ?: System.getenv("DYNAMODB_REGION") + val region = Regions.fromName(regionName) + val client = AmazonDynamoDBClientBuilder.standard().withRegion(region).build() + val db = DynamoDB(client) + val dynamoDbTableName = System.getenv("DYNAMODB_SESSIONS_TABLE") ?: "soundcloud-session" + table = db.getTable(dynamoDbTableName) + } + + /** + * Stores the stream urls of the next tracks to play and the href to fetch more tracks + * + * Completely replaces the old information + */ + fun storeNextTracks(userId: String, playback: Playback) { + table.putItem( + Item().withPrimaryKey("user", userId) + .withList("trackList", playback.trackList.map(URL::toExternalForm)) + .withInt("playPosition", playback.position) + .withLong("offsetInMilliseconds", playback.offsetInMilliseconds) + .with("nextHref", playback.soundCloudNextHref?.toExternalForm()) + .withBoolean("looping", playback.looping) + .withBoolean("shuffle", playback.shuffle) + .withString("playbackType", playback.type.toString()) + ) + } + + /** + * Load the playback session for a user. + */ + fun loadPlayback(userId: String): Playback { + val item = table.getItem("user", userId) ?: throw UserHasNoPlaybackException() + val nextTracksUrl = item.getString("nextHref") + return Playback( + trackList = item.getList("trackList").map(::URL), + soundCloudNextHref = if (nextTracksUrl == null) null else URL(nextTracksUrl), + position = item.getInt("playPosition"), + looping = item.getBoolean("looping"), + shuffle = item.getBoolean("shuffle"), + offsetInMilliseconds = item.getLong("offsetInMilliseconds"), + type = PlaybackType.valueOf(item.getString("playbackType")) + ) + } + + /** + * @param accessToken The oauth token for the user. Only needed if getting the next track while playing the users stream. Otherwise it can be null. + */ + fun getNextTrack(userId: String, accessToken: String?): Optional { + val playback = loadPlayback(userId) + val nextTrackUrl = playback.nextTrackUrl() + if (nextTrackUrl.isPresent) { + return nextTrackUrl.map { accessor.track(it) } + } else if (playback.soundCloudNextHref != null) { + val additionalTracks = when (playback.type) { + PlaybackType.track_list -> accessor.searchTracks(playback.soundCloudNextHref) + PlaybackType.stream -> { + if(accessToken == null) throw IllegalStateException("Cannot continue stream without an OAuth token. Something went wrong!") + val stream = accessor.continueStream(playback.soundCloudNextHref, accessToken) + ListResult(stream.tracks(), stream.nextHref) + } + } + + val spec = UpdateItemSpec() + .withPrimaryKey("user", userId) + .withUpdateExpression("set trackList = list_append(trackList, :additionalTracks), nextHref = :nextHref") + .withValueMap(ValueMap() + .withList(":additionalTracks", additionalTracks.collection.map { it.uri.toExternalForm() }) + .with(":nextHref", additionalTracks.nextHref?.toExternalForm()) + ) + table.updateItem(spec) + // TODO in case of the stream result this could be empty! + return Optional.of(additionalTracks.collection[0]) + } else { + return Optional.empty() + } + } + + fun getPreviousTrack(userId: String): Optional { + val playback = loadPlayback(userId) + val previousTrackUrl = playback.previousTrackUrl() + return previousTrackUrl.map { accessor.track(it) } + } + + /** + * Update the play position to the track with the given URL + */ + fun updatePosition(userId: String, trackUrl: URL) { + val playback = loadPlayback(userId) + val position = playback.trackList.indexOf(trackUrl) + if (position < 0) { + throw IllegalStateException("Could not find track $trackUrl in the playback state!") + } + val spec = UpdateItemSpec() + .withPrimaryKey("user", userId) + .withUpdateExpression("set playPosition = :newPosition") + .withValueMap(ValueMap() + .withInt(":newPosition", position) + ) + table.updateItem(spec) + } + + /** + * Updates the position of {@code trackUrl} in the track list and the current offset. Call this when you want to pause playback. + */ + fun rememberOffsetAndPosition(userId: String, trackUrl: URL, offsetInMilliseconds: Long) { + val playback = loadPlayback(userId) + val position = playback.trackList.indexOf(trackUrl) + if (position < 0) { + throw IllegalStateException("Could not find track $trackUrl in the playback state!") + } + val spec = UpdateItemSpec() + .withPrimaryKey("user", userId) + .withUpdateExpression("set offsetInMilliseconds = :offsetInMilliseconds, playPosition = :playPosition") + .withValueMap(ValueMap() + .withLong(":offsetInMilliseconds", offsetInMilliseconds) + .withInt(":playPosition", position)) + table.updateItem(spec) + } + + fun setLoopOn(userId: String) { + val spec = UpdateItemSpec() + .withPrimaryKey("user", userId) + .withUpdateExpression("set looping = :looping") + .withValueMap(ValueMap().withBoolean(":looping", true)) + table.updateItem(spec) + } + + fun setLoopOff(userId: String) { + val spec = UpdateItemSpec() + .withPrimaryKey("user", userId) + .withUpdateExpression("set looping = :looping") + .withValueMap(ValueMap().withBoolean(":looping", false)) + table.updateItem(spec) + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudSpeechletStreamHandler.kt b/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudSpeechletStreamHandler.kt new file mode 100644 index 0000000..dc63231 --- /dev/null +++ b/src/main/kotlin/de/techdev/alexa/soundcloud/SoundcloudSpeechletStreamHandler.kt @@ -0,0 +1,310 @@ +package de.techdev.alexa.soundcloud + +import com.amazon.speech.json.SpeechletRequestEnvelope +import com.amazon.speech.speechlet.* +import com.amazon.speech.speechlet.interfaces.audioplayer.* +import com.amazon.speech.speechlet.interfaces.audioplayer.directive.PlayDirective +import com.amazon.speech.speechlet.interfaces.audioplayer.directive.StopDirective +import com.amazon.speech.speechlet.interfaces.audioplayer.request.* +import com.amazon.speech.speechlet.interfaces.system.SystemInterface +import com.amazon.speech.speechlet.interfaces.system.SystemState +import com.amazon.speech.speechlet.lambda.SpeechletRequestStreamHandler +import com.amazon.speech.ui.* +import org.slf4j.LoggerFactory +import java.net.URL + +class SoundcloudSpeechletStreamHandler : SpeechletRequestStreamHandler(SoundcloudSpeechlet(), setOf(System.getenv("ALEXA_SKILL_ID"))) + +internal class SoundcloudSpeechlet : SpeechletV2, AudioPlayer { + + private val logger = LoggerFactory.getLogger(SoundcloudSpeechlet::class.java) + private val accessor = SoundcloudAccessor() + private val sessions = SoundcloudSessions(accessor) + + override fun onSessionStarted(envelope: SpeechletRequestEnvelope) { + + } + + override fun onLaunch(envelope: SpeechletRequestEnvelope): SpeechletResponse { + val translator = Translator(envelope.request.locale) + val speech = PlainTextOutputSpeech() + speech.text = translator.getTranslation("LaunchIntent.speech") + val reprompt = Reprompt() + reprompt.outputSpeech = PlaintextOutputSpeech("LaunchIntent.reprompt.speech") + return SpeechletResponse.newAskResponse(speech, reprompt) + } + + override fun onIntent(envelope: SpeechletRequestEnvelope): SpeechletResponse { + val intent = envelope.request.intent.name + val translator = Translator(envelope.request.locale) + logger.debug("Intent request {}", intent) + when (intent) { + "PlayMyFavoritesIntent" -> { + if (envelope.session.user.accessToken == null) { + val speech = PlaintextOutputSpeech(translator.getTranslation("LinkAccount.speech")) + return SpeechletResponse.newTellResponse(speech, LinkAccountCard()) + } + val tracks = accessor.getFavorites(envelope.session.user.accessToken) + if (tracks.collection.isEmpty()) { + val speech = PlaintextOutputSpeech(translator.getTranslation("PlayMyFavoritesIntent.noFavorites.speech")) + return SpeechletResponse.newTellResponse(speech) + } + sessions.storeNextTracks(envelope.session.user.userId, Playback(tracks.collection.map { it.uri }, tracks.nextHref, 0, type = PlaybackType.track_list)) + val firstTrack = tracks.collection[0] + val response = playResponse(firstTrack, PlayBehavior.REPLACE_ALL, 0) + val cardText = translator.getTranslation("PlayMyFavoritesIntent.card.text", firstTrack.title, firstTrack.user.username, firstTrack.permalinkUrl) + response.card = StandardCard(firstTrack.title, cardText, Image(firstTrack.displayImageUrl())) + return response + } + "PlayMyStreamIntent" -> { + if (envelope.session.user.accessToken == null) { + val speech = PlaintextOutputSpeech(translator.getTranslation("LinkAccount.speech")) + return SpeechletResponse.newTellResponse(speech, LinkAccountCard()) + } + val stream = accessor.getActivityStream(envelope.session.user.accessToken) + val tracks = stream.tracks() + if (tracks.isEmpty() /* && stream.nextHref == null TODO handle this differently, there might be more to load from nextHref!*/) { + val speech = PlaintextOutputSpeech(translator.getTranslation("PlayMyStreamIntent.noTracks.speech")) + val card = SimpleCard(translator.getTranslation("PlayMyStreamIntent.noTracks.card.title"), + translator.getTranslation("PlayMyStreamIntent.noTracks.card.text")) + return SpeechletResponse.newTellResponse(speech, card) + } + sessions.storeNextTracks(envelope.session.user.userId, Playback(tracks.map { it.uri }, stream.nextHref, 0, type = PlaybackType.stream)) + val firstTrack = tracks[0] + val response = playResponse(firstTrack, PlayBehavior.REPLACE_ALL, 0) + val cardText = translator.getTranslation("PlayMyStreamIntent.card.text", firstTrack.title, firstTrack.user.username, firstTrack.permalinkUrl) + response.card = StandardCard(firstTrack.title, cardText, Image(firstTrack.displayImageUrl())) + return response + } + "LikeTrackIntent" -> { + if (envelope.session.user.accessToken == null) { + val speech = PlaintextOutputSpeech(translator.getTranslation("LinkAccount.speech")) + return SpeechletResponse.newTellResponse(speech, LinkAccountCard()) + } + val audioState = envelope.context.getState(AudioPlayerInterface::class.java, AudioPlayerState::class.java) + if (audioState?.token == null) { + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("LikeTrackIntent.noPlayback"))) + } + val trackUrl = URL(audioState.token) + val track = accessor.track(trackUrl) + accessor.likeTrack(envelope.session.user.accessToken, track) + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("LikeTrackIntent.speech"))) + } + "FollowUserIntent" -> { + if (envelope.session.user.accessToken == null) { + val speech = PlaintextOutputSpeech(translator.getTranslation("LinkAccount.speech")) + return SpeechletResponse.newTellResponse(speech, LinkAccountCard()) + } + val audioState = envelope.context.getState(AudioPlayerInterface::class.java, AudioPlayerState::class.java) + if (audioState?.token == null) { + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("FollowUserIntent.noPlayback"))) + } + val trackUrl = URL(audioState.token) + val track = accessor.track(trackUrl) + val result = accessor.follow(envelope.session.user.accessToken, track.user) + + val response = SpeechletResponse() + result.ifPresent { + val cardTitle = translator.getTranslation("FollowUserIntent.card.title", track.user.username) + val cardText = translator.getTranslation("FollowUserIntent.card.text", track.user.username) + val card = StandardCard(cardTitle, cardText, Image(track.user.avatarUrl?.toExternalForm())) + response.card = card + } + response.outputSpeech = PlaintextOutputSpeech(translator.getTranslation("FollowUserIntent.speech")) + return response + } + "TellCurrentTrackIntent" -> { + val audioState = envelope.context.getState(AudioPlayerInterface::class.java, AudioPlayerState::class.java) + if (audioState?.token == null) { + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("TellCurrentTrackIntent.noPlayback"))) + } + val trackUrl = URL(audioState.token) + val track = accessor.track(trackUrl) + val cardTitle = translator.getTranslation("TellCurrentTrackIntent.card.title", track.title) + val cardText = translator.getTranslation("TellCurrentTrackIntent.card.text", track.title, track.user.username, track.permalinkUrl) + val speech = PlaintextOutputSpeech(translator.getTranslation("TellCurrentTrackIntent.speech", track.title)) + return SpeechletResponse.newTellResponse(speech, StandardCard(cardTitle, cardText, Image(track.displayImageUrl()))) + } + "AMAZON.NextIntent" -> { + val track = sessions.getNextTrack(envelope.session.user.userId, envelope.session.user.accessToken) + return track.map { playResponse(it, PlayBehavior.REPLACE_ALL, 0) }.orElse(SpeechletResponse()) + } + "AMAZON.PreviousIntent" -> { + val track = sessions.getPreviousTrack(envelope.session.user.userId) + return track.map { playResponse(it, PlayBehavior.REPLACE_ALL, 0) }.orElse(SpeechletResponse()) + } + "AMAZON.LoopOnIntent" -> { + sessions.setLoopOn(envelope.session.user.userId) + return SpeechletResponse() + } + "AMAZON.LoopOffIntent" -> { + sessions.setLoopOff(envelope.session.user.userId) + return SpeechletResponse() + } + "AMAZON.StopIntent", + "AMAZON.CancelIntent", + "AMAZON.PauseIntent" -> { + return SpeechletResponse(StopDirective()) + } + "AMAZON.ResumeIntent" -> { + val playback = sessions.loadPlayback(envelope.session.user.userId) + val track = accessor.track(playback.trackList[playback.position]) + logger.debug("Resuming playback for track {} from {}", track.uri, playback.offsetInMilliseconds) + return playResponse(track, PlayBehavior.REPLACE_ALL, playback.offsetInMilliseconds) + } + "AMAZON.RepeatIntent" -> { + val audioState = envelope.context.getState(AudioPlayerInterface::class.java, AudioPlayerState::class.java) + if (audioState?.token != null) { + val track = accessor.track(URL(audioState.token)) + logger.debug("Repeating track {}", track.uri) + return playResponse(track, PlayBehavior.REPLACE_ENQUEUED, 0) + } else { + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("RepeatIntent.noPlayback"))) + } + } + "AMAZON.ShuffleOffIntent" -> { + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("ShuffleOffIntent.speech"))) + } + "AMAZON.ShuffleOnIntent" -> { + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("ShuffleOnIntent.speech"))) + } + "AMAZON.StartOverIntent" -> { + val playback: Playback + try { + playback = sessions.loadPlayback(envelope.session.user.userId) + } catch(e: UserHasNoPlaybackException) { + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("StartOverIntent.noTracks"))) + } + if (playback.trackList.isEmpty()) { + return SpeechletResponse.newTellResponse(PlaintextOutputSpeech(translator.getTranslation("StartOverIntent.noTracks"))) + } + val track = accessor.track(playback.trackList[0]) + return playResponse(track, PlayBehavior.REPLACE_ALL, 0) + } + "AMAZON.HelpIntent" -> { + val card = SimpleCard(translator.getTranslation("HelpIntent.card.title"), translator.getTranslation("HelpIntent.card.text")) + val speech = SsmlOutputSpeech() + speech.ssml = translator.getTranslation("HelpIntent.speech") + val reprompt = Reprompt() + reprompt.outputSpeech = PlaintextOutputSpeech(translator.getTranslation("HelpIntent.reprompt.speech")) + return SpeechletResponse.newAskResponse(speech, reprompt, card) + } + else -> { + val speech = PlainTextOutputSpeech() + speech.text = translator.getTranslation("UnknownIntent.speech") + val reprompt = Reprompt() + reprompt.outputSpeech = speech + return SpeechletResponse.newAskResponse(speech, reprompt) + } + } + } + + override fun onPlaybackFailed(envelope: SpeechletRequestEnvelope): SpeechletResponse? { + val systemState = envelope.context.getState(SystemInterface::class.java, SystemState::class.java) + logger.debug("Playback failed request. Token {}, User {}", envelope.request.token, systemState.user.userId) + logger.error("Playback failed request with error: {}", envelope.request.error) + return null + } + + override fun onPlaybackStarted(envelope: SpeechletRequestEnvelope): SpeechletResponse? { + val systemState = envelope.context.getState(SystemInterface::class.java, SystemState::class.java) + logger.debug("Playback started request. Token {}, Offset {}, User {}", envelope.request.token, envelope.request.offsetInMilliseconds, systemState.user.userId) + sessions.updatePosition(systemState.user.userId, URL(envelope.request.token)) + return null + } + + override fun onPlaybackFinished(envelope: SpeechletRequestEnvelope): SpeechletResponse? = null + + /** + * Called to queue the next track. + * + * This can be called several times - e.g. after a pause/resume! + * + * It is NOT allowed to return speech or a card! + */ + override fun onPlaybackNearlyFinished(envelope: SpeechletRequestEnvelope): SpeechletResponse? { + val systemState = envelope.context.getState(SystemInterface::class.java, SystemState::class.java) + logger.debug("Playback nearly finished request. Token {}, Offset {}, User {}", envelope.request.token, envelope.request.offsetInMilliseconds, systemState.user.userId) + val track = sessions.getNextTrack(systemState.user.userId, systemState.user.accessToken) + return track.map { + logger.debug("Enqueuing track {}", it.uri) + playResponse(it, PlayBehavior.ENQUEUE, 0, expectedPreviousToken = envelope.request.token) + }.orElse(null) + } + + override fun onPlaybackStopped(envelope: SpeechletRequestEnvelope): SpeechletResponse? { + val systemState = envelope.context.getState(SystemInterface::class.java, SystemState::class.java) + logger.debug("Playback stopped request. Token {}, Offset {}, User {}", envelope.request.token, envelope.request.offsetInMilliseconds, systemState.user.userId) + sessions.rememberOffsetAndPosition(systemState.user.userId, URL(envelope.request.token), envelope.request.offsetInMilliseconds) + logger.debug("Remembering playback offset {} for track {}", envelope.request.offsetInMilliseconds, envelope.request.token) + return null + } + + private fun playResponse(track: Track, playBehavior: PlayBehavior, offsetInMilliseconds: Long, expectedPreviousToken: String? = null): SpeechletResponse { + val stream = Stream(accessor.convertTrackStreamUrl(track.streamUrl).toExternalForm(), track.uri.toExternalForm(), expectedPreviousToken, offsetInMilliseconds) + val directive = PlayDirective(stream, playBehavior) + return SpeechletResponse(directive) + } + + override fun onSessionEnded(envelope: SpeechletRequestEnvelope) { + + } +} + +fun PlaintextOutputSpeech(text: String): PlainTextOutputSpeech { + val speech = com.amazon.speech.ui.PlainTextOutputSpeech() + speech.text = text + return speech +} + +fun SpeechletResponse(vararg directives: Directive): SpeechletResponse { + val response = com.amazon.speech.speechlet.SpeechletResponse() + response.directives = directives.asList() + return response +} + +fun Stream(url: String, token: String, expectedPreviousToken: String? = null, offsetInMilliseconds: Long): Stream { + val stream = Stream() + stream.url = url + stream.token = token + if (expectedPreviousToken != null) { + stream.expectedPreviousToken = expectedPreviousToken + } + stream.offsetInMilliseconds = offsetInMilliseconds + return stream +} + +fun PlayDirective(stream: Stream, playBehavior: PlayBehavior): PlayDirective { + val audioItem = AudioItem() + audioItem.stream = stream + val directive = PlayDirective() + directive.audioItem = audioItem + directive.playBehavior = playBehavior + return directive +} + +fun Image(largeImageUrl: String?, smallImageUrl: String? = null): Image { + val image = Image() + image.largeImageUrl = largeImageUrl + if (smallImageUrl == null) { + image.smallImageUrl = largeImageUrl + } else { + image.smallImageUrl = smallImageUrl + } + return image +} + +fun StandardCard(title: String, text: String, image: Image? = null): StandardCard { + val card = StandardCard() + card.title = title + card.text = text + card.image = image + return card +} + +fun SimpleCard(title: String, content: String): SimpleCard { + val card = SimpleCard() + card.title = title + card.content = content + return card +} \ No newline at end of file diff --git a/src/main/kotlin/de/techdev/alexa/soundcloud/Translator.kt b/src/main/kotlin/de/techdev/alexa/soundcloud/Translator.kt new file mode 100644 index 0000000..870deab --- /dev/null +++ b/src/main/kotlin/de/techdev/alexa/soundcloud/Translator.kt @@ -0,0 +1,21 @@ +package de.techdev.alexa.soundcloud + +import java.text.MessageFormat +import java.util.* + +class Translator(private val locale: Locale) { + + private val bundle = ResourceBundle.getBundle("messages", locale) + + fun getTranslation(key: String): String = getStringAsUTF8(key) + + fun getTranslation(key: String, vararg foo: Any): String { + val formatter = MessageFormat(getStringAsUTF8(key), locale) + return formatter.format(foo) + } + + /** + * ResourceBundle assumes read Strings to be ISO encoded, so we convert it to UTF-8. + */ + private fun getStringAsUTF8(key: String): String = String(bundle.getString(key).toByteArray(Charsets.ISO_8859_1), Charsets.UTF_8) +} \ No newline at end of file diff --git a/src/main/resources/log4j.properties b/src/main/resources/log4j.properties new file mode 100644 index 0000000..c2112bc --- /dev/null +++ b/src/main/resources/log4j.properties @@ -0,0 +1,8 @@ +log=. +log4j.rootLogger=INFO, LAMBDA +log4j.logger.de.techdev=DEBUG + +#Define the LAMBDA appender +log4j.appender.LAMBDA=com.amazonaws.services.lambda.runtime.log4j.LambdaAppender +log4j.appender.LAMBDA.layout=org.apache.log4j.PatternLayout +log4j.appender.LAMBDA.layout.conversionPattern=%d{yyyy-MM-dd HH:mm:ss} <%X{AWSRequestId}> %-5p %c{1}:%L - %m%n \ No newline at end of file diff --git a/src/main/resources/messages_de_DE.properties b/src/main/resources/messages_de_DE.properties new file mode 100644 index 0000000..c30fb3c --- /dev/null +++ b/src/main/resources/messages_de_DE.properties @@ -0,0 +1,46 @@ +FollowUserIntent.card.text=Du folgst jetzt {0} auf SoundCloud! +FollowUserIntent.card.title=Folge {0} +FollowUserIntent.noPlayback=Ich spiele gerade keine Musik und kann niemandem folgen. +HelpIntent.card.text=Spiele deinen Stream mit\n\ +"Spiele meinen Stream"\n\ +Spiele deine Favoriten mit\n\ +"Spiele meine Favoriten"\n\ +\n\ +Wenn du wissen möchtest was gespielt wird frage\n\ +"Alexa, frage Cloud Music was gerade läuft"\n\ +\n\ +Um einen Song zu liken sage\n\ +"Alexa, sage Cloud Music mir gefällt der Song"\n\ +Um dem User zu folgen sage\n\ +"Alexa, sage Cloud Music folge dem User" +HelpIntent.card.title=Cloud Musik Hilfe +HelpIntent.reprompt.speech=Was möchtest du hören? +HelpIntent.speech=\ +Du kannst mit "Spiele meinen Stream" deinen Stream abspielen\ +Mit "Spiele meine Favoriten" spiele ich deine Lieblingslieder\ +Während ein Song läuft kannst du mich "Welcher Song ist das?" fragen\ +Um einen Song zu liken sage mir "Like diesen Song" oder "Mir gefällt das"\ +Wenn du dem User folgen möchtest sage mir Folge dem User\ +Was möchtest du hören?\ + +LaunchIntent.reprompt.speech=Was möchtest du hören? +LaunchIntent.speech=Willkommen zum Cloud Musik Skill! Du kannst mich bitten, deinen Stream oder deine Favoriten zu spielen. Was möchtest du hören? +LikeTrackIntent.noPlayback=Ich spiele gerade nichts was ich liken könnte. +LinkAccount.speech=Bitte verknüpfe dein SoundCloud Konto. +PlayMyFavoritesIntent.card.text=Ich spiele jetzt deine Favoriten, angefangen mit {0}.\nHochgeladen von {1}\n{2} +PlayMyFavoritesIntent.noFavorites.speech=Du hast keine Favoriten. +PlayMyStreamIntent.card.text=Ich spiele jetzt deinen Stream, angefangen mit {0}.\n Hochgeladen von {1}\n{2} +PlayMyStreamIntent.noTracks.speech=Ich konnte keine Songs in deinem Stream finden. Du musst erst Benutzern auf SoundCloud folgen bevor ich deinen Stream spielen kann. +PlayMyStreamIntent.noTracks.card.text=Ich konnte keine Songs in deinem Stream finden. Bitte folge erst einigen Benutzern auf soundcloud.com. +PlayMyStreamIntent.noTracks.card.title=Keine Songs in deinem Stream +RepeatIntent.noPlayback=Ich spiele gerade nichts was ich wiederholen kann. +ShuffleOffIntent.speech=Ich kann deine Musik nicht zufällig abspielen. +ShuffleOnIntent.speech=Ich kann deine Musik nicht zufällig abspielen. +StartOverIntent.noTracks=Ich habe nichts was ich von vorne spielen kann. +TellCurrentTrackIntent.card.text=Ich spiele gerade {0}, hochgeladen von {1}\n{2} +TellCurrentTrackIntent.card.title=Es läuft {0} +TellCurrentTrackIntent.noPlayback=Ich spiele gerade nichts ab. +UnknownIntent.speech=Ich konnte dich leider nicht verstehen. Bitte versuche es erneut. +LikeTrackIntent.speech=Ok, ich habe den Song zu deinen Favoriten hinzugefügt. +FollowUserIntent.speech=Ok, du folgst jetzt dem Benutzer! +TellCurrentTrackIntent.speech=Ich spiele gerade: {0} \ No newline at end of file diff --git a/src/main/resources/messages_en_UK.properties b/src/main/resources/messages_en_UK.properties new file mode 100644 index 0000000..c1f1c12 --- /dev/null +++ b/src/main/resources/messages_en_UK.properties @@ -0,0 +1,46 @@ +FollowUserIntent.card.text=You are now following {0} on SoundCloud! +FollowUserIntent.card.title=Following {0} +FollowUserIntent.noPlayback=I am not playing any music currently and can't follow anyone. +HelpIntent.card.text=Play your stream with\n\ +"Play my stream"\n\ +Play your favourites with\n\ +"Play my favourites"\n\ +\n\ +If you want to know what is playing ask\n\ +"Alexa, as Cloud Music what is currently playing"\n\ +\n\ +To like a song tell me\n\ +"Alexa, tell Cloud Music to like this song"\n\ +To follow the user tell me\n\ +"Alexa, tell Cloud Music to follow the user" +HelpIntent.card.title=Cloud Music help +HelpIntent.reprompt.speech=What would you like to listen to? +HelpIntent.speech=\ +You can play your stream with "Play my stream"\ +If you tell me to "Play my favourites" I will play your favourite songs\ +While a song is playing you can ask me "What song is this?"\ +To like a song tell me "Like this song"\ +If you want to follow the user say "Follow the user"\ +What would you like to listen to?\ + +LaunchIntent.reprompt.speech=What would you like to listen to? +LaunchIntent.speech=Welcome to the Cloud Music skill! You can ask me to play your stream or your favourites. What would you like to listen to? +LikeTrackIntent.noPlayback=I am not playing something I can like for you. +LinkAccount.speech=Please link your Soundcloud account. +PlayMyFavoritesIntent.card.text=Now playing your favorites, starting with {0} uploaded by {1}\n{2} +PlayMyFavoritesIntent.noFavorites.speech=You don't have any favorites. +PlayMyStreamIntent.card.text=Now playing your stream, starting with {0} uploaded by {1}\n{2} +PlayMyStreamIntent.noTracks.speech=I could not find anything in your stream. You first have to follow users on SoundCloud before I can play your stream. +PlayMyStreamIntent.noTracks.card.text=I could not find anything in your stream. Please first follow some users on soundcloud.com. +PlayMyStreamIntent.noTracks.card.title=No songs in your stream +RepeatIntent.noPlayback=I am not playing anything I can repeat currently. +ShuffleOffIntent.speech=I can't shuffle your music. +ShuffleOnIntent.speech=I can't shuffle your music. +StartOverIntent.noTracks=I don't have anything to start over. +TellCurrentTrackIntent.card.text=I am currently playing {0} uploaded by {1}\n${2} +TellCurrentTrackIntent.card.title=Currently playing {0} +TellCurrentTrackIntent.noPlayback=I am not playing anything currently. +UnknownIntent.speech=I could not understand you. Please try again. +LikeTrackIntent.speech=Ok, I liked the song for you! +FollowUserIntent.speech=Ok, you are now following the user! +TellCurrentTrackIntent.speech=I am playing {0} currently \ No newline at end of file diff --git a/src/main/resources/messages_en_US.properties b/src/main/resources/messages_en_US.properties new file mode 100644 index 0000000..81874a0 --- /dev/null +++ b/src/main/resources/messages_en_US.properties @@ -0,0 +1,46 @@ +FollowUserIntent.card.text=You are now following {0} on SoundCloud! +FollowUserIntent.card.title=Following {0} +FollowUserIntent.noPlayback=I am not playing any music currently and can't follow anyone. +HelpIntent.card.text=Play your stream with\n\ +"Play my stream"\n\ +Play your favorites with\n\ +"Play my favorites"\n\ +\n\ +If you want to know what is playing ask\n\ +"Alexa, as Cloud Music what is currently playing"\n\ +\n\ +To like a song tell me\n\ +"Alexa, tell Cloud Music to like this song"\n\ +To follow the user tell me\n\ +"Alexa, tell Cloud Music to follow the user" +HelpIntent.card.title=Cloud Music help +HelpIntent.reprompt.speech=What would you like to listen to? +HelpIntent.speech=\ +You can play your stream with "Play my stream"\ +If you tell me to "Play my favorites" I will play your favorite songs\ +While a song is playing you can ask me "What song is this?"\ +To like a song tell me "Like this song"\ +If you want to follow the user say "Follow the user"\ +What would you like to listen to?\ + +LaunchIntent.reprompt.speech=What would you like to listen to? +LaunchIntent.speech=Welcome to the Cloud Music skill! You can ask me to play your stream or your favorites. What would you like to listen to? +LikeTrackIntent.noPlayback=I am not playing something I can like for you. +LinkAccount.speech=Please link your Soundcloud account. +PlayMyFavoritesIntent.card.text=Now playing your favourites, starting with {0} uploaded by {1}\n{2} +PlayMyFavoritesIntent.noFavorites.speech=You don't have any favorites. +PlayMyStreamIntent.card.text=Now playing your stream, starting with {0} uploaded by {1}\n{2} +PlayMyStreamIntent.noTracks.speech=I could not find anything in your stream. You first have to follow users on SoundCloud before I can play your stream. +PlayMyStreamIntent.noTracks.card.text=I could not find anything in your stream. Please first follow some users on soundcloud.com. +PlayMyStreamIntent.noTracks.card.title=No songs in your stream +RepeatIntent.noPlayback=I am not playing anything I can repeat currently. +ShuffleOffIntent.speech=I can't shuffle your music. +ShuffleOnIntent.speech=I can't shuffle your music. +StartOverIntent.noTracks=I don't have anything to start over. +TellCurrentTrackIntent.card.text=I am currently playing {0} uploaded by {1}\n${2} +TellCurrentTrackIntent.card.title=Currently playing {0} +TellCurrentTrackIntent.noPlayback=I am not playing anything currently. +UnknownIntent.speech=I could not understand you. Please try again. +LikeTrackIntent.speech=Ok, I liked the song for you! +FollowUserIntent.speech=Ok, you are now following the user! +TellCurrentTrackIntent.speech=I am playing {0} currently \ No newline at end of file