Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add spotify playlist support #52

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ This Arch package provides a systemd service for ukulele, and places the files f
- As noted when installing the package, the discord bot token must be set in the config file ([guide](https://discordjs.guide/preparations/setting-up-a-bot-application.html))
- Start/enable the `ukulele.service` as required ([wiki](https://wiki.archlinux.org/title/Systemd#Using_units))

## Spotify Support
Only support for Spotify playlists has been implemented thus far. Both Spotify URL's (`https://open.spotify.com/playlist/...`) and URI's (`spotify:playlist:...`) are supported.

To enable it, the `spotifyClientId` and `spotifyClientSecret` fields of `ukulele.yml` must be populated with the `clientID` and `clientSecret` of a Spotify application.
See [Spotify's Guide](https://developer.spotify.com/documentation/general/guides/authorization/app-settings/) for creating an application.
You don't need to “Configure” the application, just create a `clientId` and `clientSecret`.

Once they are set, running the bot will have `Successfully loaded Spotify API` in the logs.

NOTE: Spotify is only used to fetch metadata (titles, artists, etc), not the tracks themselves.
Instead, the track title and artist name(s) are searched (format `$TITLE $ARTIST1 $ARTIST2 ...`) on YouTube Music, and the first result is used for the track itself.

## Contributing
Pull requests are welcome! Look through the issues and/or create one if you have an idea.

Expand Down
14 changes: 8 additions & 6 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.springframework.boot.gradle.tasks.bundling.BootJar

plugins {
id("org.springframework.boot") version "2.3.5.RELEASE"
id("io.spring.dependency-management") version "1.0.10.RELEASE"
kotlin("jvm") version "1.4.10"
kotlin("plugin.spring") version "1.4.10"
id("org.springframework.boot") version "2.4.4"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.4.32"
kotlin("plugin.spring") version "1.4.32"
}

group = "dev.arbjerg"
Expand All @@ -23,16 +23,18 @@ dependencies {
//implementation("com.sedmelluq:lavaplayer:1.3.78")
implementation("com.github.walkyst:lavaplayer-fork:1.3.96")
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
implementation("com.adamratzman:spotify-api-kotlin-core:3.8.5")
implementation("commons-validator:commons-validator:1.7")

runtimeOnly("com.h2database:h2")
implementation("io.r2dbc:r2dbc-h2")
implementation("org.flywaydb:flyway-core")
implementation("com.github.ben-manes.caffeine:caffeine:2.8.6")
implementation("com.github.ben-manes.caffeine:caffeine:3.0.5")


implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.4.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
Expand Down
117 changes: 109 additions & 8 deletions src/main/kotlin/dev/arbjerg/ukulele/command/PlayCommand.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.arbjerg.ukulele.command

import com.adamratzman.spotify.SpotifyAppApi
import com.adamratzman.spotify.models.SpotifyUriException
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException
Expand All @@ -12,18 +14,45 @@ import dev.arbjerg.ukulele.features.HelpContext
import dev.arbjerg.ukulele.jda.Command
import dev.arbjerg.ukulele.jda.CommandContext
import net.dv8tion.jda.api.Permission
import org.apache.commons.validator.routines.UrlValidator
import org.springframework.stereotype.Component
import java.net.URL

@Component
class PlayCommand(
val players: PlayerRegistry,
val apm: AudioPlayerManager,
val botProps: BotProps
val botProps: BotProps,
val spotify: SpotifyAppApi?,
val urlValidator: UrlValidator
) : Command("play", "p") {
private companion object {
@JvmStatic val SEARCH_PREFIX_REGEX: Regex by lazy {
"""^.+search:""".toRegex()
}
}

override suspend fun CommandContext.invoke() {
if (!ensureVoiceChannel()) return
val identifier = argumentText
players.get(guild, guildProperties).lastChannel = channel

if (spotify != null) {
try {
val spotifyUri = spotifyUrlToUri(identifier) ?: identifier;
val searches = convertSpotifyUri(spotifyUri)
if (searches != null && searches.isNotEmpty()) {
val logAddition = searches.size == 1;
searches.forEach {
apm.loadItemOrdered(identifier, it, Loader(this, player, it, logAddition, true))
}
reply("Added ${searches.size} tracks from ${getNameFromSpotifyUri(spotifyUri)}")
return
}
} catch (_: SpotifyUriException) {
// Identifier isn't a spotify URI
}
}
apm.loadItem(identifier, Loader(this, player, identifier))
}

Expand Down Expand Up @@ -51,21 +80,90 @@ class PlayCommand(
return ourVc != null
}

private suspend fun convertSpotifyUri(identifier: String): List<String>? {
if (!identifier.startsWith("spotify:")) {
return null;
}

val split = identifier.split(":");
if (split.size != 3) {
return null;
}

when (split[1]) {
"playlist" -> {
val spotifyUri = spotify?.playlists?.getPlaylist(identifier);
if (spotifyUri != null) {
return spotifyUri.tracks.getAllItemsNotNull()
.filter { playlistTrack -> playlistTrack.isLocal == false }
.filter { playlistTrack -> playlistTrack.track?.asTrack?.name != null }
.map { playlistTrack -> "ytmsearch:" + playlistTrack.track?.asTrack?.name + " " + (playlistTrack.track?.asTrack?.artists?.joinToString(" ") { it.name } ?: "") }
}
}
else -> {return null}
}
return null
}

private suspend fun getNameFromSpotifyUri(identifier: String): String? {
if (!identifier.startsWith("spotify:")) {
return null;
}

val split = identifier.split(":");
if (split.size != 3) {
return null;
}

when (split[1]) {
"playlist" -> {
val spotifyUri = spotify?.playlists?.getPlaylist(identifier);
if (spotifyUri != null) {
return spotifyUri.name
}
}
else -> {return null}
}
return null
}

private fun spotifyUrlToUri(identifier: String): String? {
if (!urlValidator.isValid(identifier)) {
return null
}

val url = URL(identifier)
if (!url.host.endsWith("spotify.com")) {
return null
}

val split = url.path.split("/").filter { it.isNotBlank() }
if (split.size != 2) {
return null
}

return "spotify:" + split[0] + ":" + split[1]
}

inner class Loader(
private val ctx: CommandContext,
private val player: Player,
private val identifier: String
private val identifier: String,
private val logAddition: Boolean = true,
private val isSearch: Boolean = false
) : AudioLoadResultHandler {
override fun trackLoaded(track: AudioTrack) {
if (track.isOverDurationLimit) {
ctx.reply("Refusing to play `${track.info.title}` because it is over ${botProps.trackDurationLimit} minutes long")
return
}
val started = player.add(track)
if (started) {
ctx.reply("Started playing `${track.info.title}`")
} else {
ctx.reply("Added `${track.info.title}`")
if (logAddition) {
if (started) {
ctx.reply("Started playing `${track.info.title}`")
} else {
ctx.reply("Added `${track.info.title}`")
}
}
}

Expand All @@ -77,7 +175,7 @@ class PlayCommand(
return
}

if (identifier.startsWith("ytsearch") || identifier.startsWith("ytmsearch") || identifier.startsWith("scsearch:")) {
if (isSearch || SEARCH_PREFIX_REGEX.matches(identifier)) {
this.trackLoaded(accepted.component1());
return
}
Expand All @@ -90,7 +188,10 @@ class PlayCommand(
}

override fun noMatches() {
ctx.reply("Nothing found for “$identifier”")
ctx.reply(
"Nothing found for “${if (isSearch) {
SEARCH_PREFIX_REGEX.replaceFirst(identifier, "")} else {identifier}}”"
)
}

override fun loadFailed(exception: FriendlyException) {
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/dev/arbjerg/ukulele/config/BotProps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ class BotProps(
var database: String = "./database",
var game: String = "",
var trackDurationLimit: Int = 0,
var announceTracks: Boolean = false
var announceTracks: Boolean = false,
var spotifyClientId: String?,
var spotifyClientSecret: String?
)
20 changes: 20 additions & 0 deletions src/main/kotlin/dev/arbjerg/ukulele/config/SpotifyConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dev.arbjerg.ukulele.config

import com.adamratzman.spotify.SpotifyAppApi
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import com.adamratzman.spotify.spotifyAppApi
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty

@Configuration
class SpotifyConfig {
private val LOGGER = LoggerFactory.getLogger(SpotifyConfig::class.java);

@Bean
@ConditionalOnProperty(prefix = "config", value = ["spotifyClientId", "spotifyClientSecret"])
fun spotifyAppApiBean(props: BotProps): SpotifyAppApi {
return runBlocking { spotifyAppApi(props.spotifyClientId!!, props.spotifyClientSecret!!).build().also { LOGGER.info("Successfully loaded Spotify API") } }
}
}
13 changes: 13 additions & 0 deletions src/main/kotlin/dev/arbjerg/ukulele/config/ValidatorConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package dev.arbjerg.ukulele.config

import org.apache.commons.validator.routines.UrlValidator
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class ValidatorConfig {
@Bean
fun urlValidator(): UrlValidator {
return UrlValidator(arrayOf("https"))
}
}
2 changes: 2 additions & 0 deletions ukulele.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ config:
game: "" # Status message shown when your bot is online
trackDurationLimit: 0 # Maximum limit of track duration in minutes. Set to 0 for unlimited
announceTracks: false # Announce the start of a track
spotifyClientId: # Optional Spotify client ID
spotifyClientSecret: # Optional Spotify client secret