Skip to content

Commit 73c7ae7

Browse files
committed
Add direct Spotify playback support
1 parent 5afe3a1 commit 73c7ae7

38 files changed

+2544
-49
lines changed

Dockerfile

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,37 @@ RUN mvn -q -DskipTests package && \
99
mkdir -p /out && \
1010
cp target/*-All.jar /out/JMusicBot.jar
1111

12-
FROM eclipse-temurin:17-jre-jammy
12+
FROM golang:1.25-bookworm AS golibrespot-build
13+
WORKDIR /src
14+
15+
RUN apt-get update && apt-get install -y --no-install-recommends \
16+
git \
17+
pkg-config \
18+
libogg-dev \
19+
libvorbis-dev \
20+
flac \
21+
libflac-dev \
22+
libasound2-dev && \
23+
rm -rf /var/lib/apt/lists/*
24+
25+
RUN git clone --depth 1 --branch v0.7.1 https://github.com/devgianlu/go-librespot.git go-librespot
26+
WORKDIR /src/go-librespot
27+
RUN go build -o /out/go-librespot ./cmd/daemon
28+
29+
FROM eclipse-temurin:17-jre
1330
WORKDIR /app
1431

32+
RUN apt-get update && apt-get install -y --no-install-recommends \
33+
ffmpeg \
34+
ca-certificates \
35+
libasound2t64 && \
36+
rm -rf /var/lib/apt/lists/*
37+
1538
COPY --from=build /out/JMusicBot.jar /app/JMusicBot.jar
39+
COPY --from=golibrespot-build /out/go-librespot /usr/local/bin/go-librespot
1640
COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
1741

18-
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
42+
RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/go-librespot
1943

2044
ENV JMUSICBOT_HOME=/data
2145
VOLUME ["/data"]

README.md

Lines changed: 176 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ This repository is a maintained fork focused on keeping YouTube playback working
1717

1818
Important differences from the original project:
1919
1. This fork supports YouTube OAuth playback fallback.
20-
2. If you want reliable playback for blocked or age-restricted videos, you should use a dedicated burner Google account.
21-
3. Do not use your main Google account.
22-
4. If the burner account cannot play a video in the normal YouTube browser UI, the bot will usually not be able to play it either.
20+
2. This fork supports direct Spotify playback through a local `go-librespot` backend.
21+
3. Spotify support is per-bot and can be enabled or disabled independently at startup.
22+
4. If two bots use the same Spotify account, they will compete for the same Spotify Connect session.
23+
5. For parallel Spotify playback on two bots, use two separate Spotify accounts.
24+
6. If you want reliable playback for blocked or age-restricted videos, you should use a dedicated burner Google account.
25+
7. Do not use your main Google account.
26+
8. If the burner account cannot play a video in the normal YouTube browser UI, the bot will usually not be able to play it either.
2327

2428
## YouTube OAuth Setup For This Fork
2529

@@ -143,6 +147,175 @@ Container-specific behavior:
143147
2. Relative state paths are resolved from `JMUSICBOT_HOME`, which defaults to `/data` in the container.
144148
3. Each bot keeps its own `config.txt`, `serversettings.json`, and `Playlists/` under its own instance directory.
145149

150+
## Spotify Direct Playback
151+
152+
This fork can play Spotify tracks, albums, and playlists directly without YouTube conversion.
153+
154+
### What this does
155+
156+
1. Spotify URLs are resolved as Spotify content, not mapped to YouTube.
157+
2. Playback is done through a local `go-librespot` backend plus an `ffmpeg` bridge to Discord voice.
158+
3. The feature is enabled per bot instance, not globally for the whole host.
159+
160+
### What this does not do
161+
162+
1. It does not use the official Spotify Web API for full audio playback.
163+
2. It does not allow two bots on the same Spotify account to play different things at the same time.
164+
3. It does not bypass normal Spotify account limits or Spotify Connect session rules.
165+
166+
### Requirements
167+
168+
1. Spotify playback is optional and disabled by default.
169+
2. It requires the Docker image or runtime environment to start the bundled `go-librespot` sidecar.
170+
3. It requires a Spotify Premium account for playback.
171+
4. The bot instance must have persistent writable storage under its data directory.
172+
173+
### Integration overview
174+
175+
For one bot instance with Spotify enabled, the runtime does the following:
176+
1. starts the Java bot process
177+
2. starts a local `go-librespot` daemon
178+
3. creates a local PCM pipe under the bot data directory
179+
4. authenticates the selected Spotify account
180+
5. uses that backend for Spotify URLs while keeping normal lavaplayer behavior for non-Spotify URLs
181+
182+
### Per-bot enable or disable
183+
184+
Spotify is controlled per bot instance through environment variables.
185+
186+
Example:
187+
188+
```env
189+
SPOTIFY_ENABLED=true
190+
SPOTIFY_DEVICE_NAME=devshmusic-test1-spotify
191+
SPOTIFY_CALLBACK_PORT=0
192+
```
193+
194+
If `SPOTIFY_ENABLED=false` or unset:
195+
1. the Spotify sidecar is not started
196+
2. direct Spotify playback is disabled for that bot
197+
3. the bot continues to work as a normal YouTube/lavaplayer bot
198+
199+
This means you can run:
200+
1. one bot with Spotify enabled
201+
2. another bot with Spotify disabled
202+
3. both at the same time on the same host
203+
204+
### Minimal bot env for Spotify
205+
206+
The smallest useful setup in `bot.env` is:
207+
208+
```env
209+
BOT_TOKEN=replace_with_discord_bot_token
210+
BOT_OWNER=replace_with_discord_owner_id
211+
SPOTIFY_ENABLED=true
212+
SPOTIFY_DEVICE_NAME=devshmusic-spotify-1
213+
SPOTIFY_CALLBACK_PORT=0
214+
```
215+
216+
Notes:
217+
1. `SPOTIFY_DEVICE_NAME` should be unique per bot.
218+
2. `SPOTIFY_CALLBACK_PORT=0` lets the backend choose a free local callback port automatically.
219+
3. If Spotify is disabled for a bot, you can omit all Spotify variables entirely.
220+
221+
### First-time Spotify setup from scratch
222+
223+
When `SPOTIFY_ENABLED=true` and no prior Spotify state exists:
224+
1. pick the bot instance that should get Spotify support
225+
2. set `SPOTIFY_ENABLED=true` in that bot's `bot.env`
226+
3. make sure the instance has its own persistent data directory
227+
4. start or restart that bot container
228+
5. watch the logs for the Spotify authorization URL
229+
6. open the URL in a browser
230+
7. log into the Spotify Premium account intended for that bot
231+
8. finish the callback flow
232+
9. let the bot finish initialization
233+
10. test a Spotify track, album, or playlist URL on Discord
234+
235+
After that, restarts should reuse the stored Spotify session state.
236+
237+
### Where Spotify state is stored
238+
239+
For a bot instance running with `JMUSICBOT_HOME=/data`, the Spotify runtime stores:
240+
1. Spotify auth and daemon state under `/data/spotify`
241+
2. the PCM pipe under `/data/spotify.pipe`
242+
243+
This means:
244+
1. deleting the instance data directory will force a fresh Spotify login
245+
2. moving the instance to another host requires moving the bot's data volume as well
246+
247+
### Recommended deployment patterns
248+
249+
#### One bot with Spotify, one bot without Spotify
250+
251+
This is the cleanest production split.
252+
253+
Example:
254+
1. `bot-a`: `SPOTIFY_ENABLED=true`
255+
2. `bot-b`: `SPOTIFY_ENABLED=false`
256+
257+
Result:
258+
1. `bot-a` can handle Spotify URLs and normal YouTube URLs
259+
2. `bot-b` stays a normal non-Spotify bot
260+
3. only one container pays the operational cost of the Spotify sidecar
261+
262+
#### Two bots with Spotify enabled
263+
264+
This is valid only if you understand the account model.
265+
266+
1. If both bots use the same Spotify account, they will fight over one Spotify Connect session.
267+
2. If you want both bots to play Spotify independently, use two separate Spotify accounts.
268+
3. Give each bot a unique `SPOTIFY_DEVICE_NAME`.
269+
270+
### Multiple bots and account isolation
271+
272+
1. Two bots can both have Spotify enabled.
273+
2. If both bots use the same Spotify account, they will fight over one Spotify Connect session.
274+
3. If you need two bots to play different Spotify content at the same time, use two separate Spotify accounts.
275+
276+
### Production checklist
277+
278+
Before enabling Spotify on a production bot:
279+
1. verify the bot can still play a normal YouTube URL
280+
2. verify the bot can play a Spotify track URL
281+
3. verify the bot can play a Spotify playlist URL
282+
4. verify `skip`, `pause`, `resume`, `seek`, `volume`, and `queue`
283+
5. confirm the instance data directory is persistent
284+
6. confirm no second bot is using the same Spotify account unless that is intentional
285+
286+
### Troubleshooting
287+
288+
#### The bot ignores Spotify URLs
289+
290+
Check:
291+
1. `SPOTIFY_ENABLED=true` is actually present in that bot's runtime env
292+
2. the bot instance was restarted after editing env
293+
3. the Spotify sidecar started successfully in logs
294+
295+
#### The bot asks for Spotify login every restart
296+
297+
Check:
298+
1. the bot is using persistent storage
299+
2. `/data/spotify` is not being discarded between restarts
300+
3. the container is not mounting a fresh empty instance directory
301+
302+
#### Two Spotify bots keep interrupting each other
303+
304+
Cause:
305+
1. both bots are logged into the same Spotify account
306+
307+
Fix:
308+
1. use one Spotify-enabled bot and one non-Spotify bot
309+
2. or use two separate Spotify accounts
310+
311+
#### Spotify works but YouTube stops working
312+
313+
This should not happen by design. The fork keeps Spotify and lavaplayer on separate playback paths.
314+
If this happens:
315+
1. test a plain YouTube URL on the same bot
316+
2. check bot logs for the failure path
317+
3. verify the bot is not stuck in an active Spotify session that should first be stopped
318+
146319
## GHCR Container Publishing
147320

148321
GitHub Actions now builds the Docker image from this repository and publishes it to GHCR.

docker/docker-entrypoint.sh

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,56 @@ if [ ! -f "$SETTINGS_FILE" ]; then
2121
fi
2222
fi
2323

24+
if [ "${SPOTIFY_ENABLED:-false}" = "true" ]; then
25+
SPOTIFY_CONFIG_DIR="${SPOTIFY_CONFIG_DIR:-$DATA_DIR/spotify}"
26+
SPOTIFY_PIPE_PATH="${SPOTIFY_PIPE_PATH:-$DATA_DIR/spotify.pipe}"
27+
SPOTIFY_API_PORT="${SPOTIFY_API_PORT:-3678}"
28+
SPOTIFY_API_URL="${SPOTIFY_API_URL:-http://127.0.0.1:$SPOTIFY_API_PORT}"
29+
SPOTIFY_FFMPEG_PATH="${SPOTIFY_FFMPEG_PATH:-ffmpeg}"
30+
SPOTIFY_CALLBACK_PORT="${SPOTIFY_CALLBACK_PORT:-0}"
31+
SPOTIFY_DEVICE_NAME="${SPOTIFY_DEVICE_NAME:-JMusicBot Spotify}"
32+
SPOTIFY_VOLUME_STEPS="${SPOTIFY_VOLUME_STEPS:-150}"
33+
SPOTIFY_INITIAL_VOLUME="${SPOTIFY_INITIAL_VOLUME:-100}"
34+
SPOTIFY_BITRATE="${SPOTIFY_BITRATE:-320}"
35+
SPOTIFY_CREDENTIALS_TYPE="${SPOTIFY_CREDENTIALS_TYPE:-interactive}"
36+
SPOTIFY_STATE_CONFIG="$SPOTIFY_CONFIG_DIR/config.yml"
37+
38+
mkdir -p "$SPOTIFY_CONFIG_DIR"
39+
rm -f "$SPOTIFY_PIPE_PATH"
40+
mkfifo "$SPOTIFY_PIPE_PATH"
41+
42+
if [ ! -f "$SPOTIFY_STATE_CONFIG" ]; then
43+
cat > "$SPOTIFY_STATE_CONFIG" <<EOF
44+
log_level: info
45+
device_name: "$SPOTIFY_DEVICE_NAME"
46+
device_type: speaker
47+
audio_backend: pipe
48+
audio_output_pipe: "$SPOTIFY_PIPE_PATH"
49+
audio_output_pipe_format: s16le
50+
bitrate: $SPOTIFY_BITRATE
51+
volume_steps: $SPOTIFY_VOLUME_STEPS
52+
initial_volume: $SPOTIFY_INITIAL_VOLUME
53+
external_volume: true
54+
disable_autoplay: false
55+
zeroconf_enabled: false
56+
credentials:
57+
type: $SPOTIFY_CREDENTIALS_TYPE
58+
interactive:
59+
callback_port: $SPOTIFY_CALLBACK_PORT
60+
server:
61+
enabled: true
62+
address: 127.0.0.1
63+
port: $SPOTIFY_API_PORT
64+
EOF
65+
fi
66+
67+
export SPOTIFY_API_URL
68+
export SPOTIFY_PIPE_PATH
69+
export SPOTIFY_FFMPEG_PATH
70+
71+
/usr/local/bin/go-librespot --config_dir "$SPOTIFY_CONFIG_DIR" &
72+
fi
73+
2474
exec java ${JAVA_OPTS:-} \
2575
-Dnogui=true \
2676
-Dconfig.file="$CONFIG_FILE" \

docker/instances/_template/bot.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ BOT_OWNER=497820759096295429
44
# YT_OAUTH=false
55
# YT_OAUTH_REFRESH_TOKEN=
66
# JAVA_OPTS=-Xms256m -Xmx768m
7+
# SPOTIFY_ENABLED=true
8+
# SPOTIFY_DEVICE_NAME=devshmusic-test1-spotify
9+
# SPOTIFY_CALLBACK_PORT=0

src/main/java/com/jagrosh/jmusicbot/Bot.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ public void shutdown()
142142
if(ah!=null)
143143
{
144144
ah.stopAndClear();
145-
ah.getPlayer().destroy();
145+
ah.destroy();
146146
}
147147
});
148148
jda.shutdown();

src/main/java/com/jagrosh/jmusicbot/BotConfig.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ public class BotConfig
3939
private static final String YT_VISITOR_DATA_ENV = "YT_VISITOR_DATA";
4040
private static final String YT_OAUTH_ENV = "YT_OAUTH";
4141
private static final String YT_OAUTH_REFRESH_TOKEN_ENV = "YT_OAUTH_REFRESH_TOKEN";
42+
private static final String SPOTIFY_ENABLED_ENV = "SPOTIFY_ENABLED";
43+
private static final String SPOTIFY_API_URL_ENV = "SPOTIFY_API_URL";
44+
private static final String SPOTIFY_PIPE_PATH_ENV = "SPOTIFY_PIPE_PATH";
45+
private static final String SPOTIFY_FFMPEG_PATH_ENV = "SPOTIFY_FFMPEG_PATH";
4246
private final Prompt prompt;
4347
private final static String CONTEXT = "Config";
4448
private final static String START_TOKEN = "/// START OF JMUSICBOT CONFIG ///";
@@ -47,8 +51,9 @@ public class BotConfig
4751
private Path path = null;
4852
private String token, prefix, altprefix, helpWord, playlistsFolder, logLevel,
4953
successEmoji, warningEmoji, errorEmoji, loadingEmoji, searchingEmoji,
50-
ytPoToken, ytVisitorData, ytOauthRefreshToken, evalEngine;
51-
private boolean stayInChannel, songInGame, npImages, updatealerts, useEval, dbots, ytOauth;
54+
ytPoToken, ytVisitorData, ytOauthRefreshToken, evalEngine,
55+
spotifyApiUrl, spotifyPipePath, spotifyFfmpegPath;
56+
private boolean stayInChannel, songInGame, npImages, updatealerts, useEval, dbots, ytOauth, spotifyEnabled;
5257
private long owner, maxSeconds, aloneTimeUntilStop;
5358
private int maxYTPlaylistPages;
5459
private double skipratio;
@@ -106,6 +111,10 @@ public void load()
106111
ytVisitorData = getEnvOverride(YT_VISITOR_DATA_ENV, config.getString("ytvisitordata"));
107112
ytOauth = getBooleanOverride(YT_OAUTH_ENV, config.getBoolean("ytoauth"));
108113
ytOauthRefreshToken = getEnvOverride(YT_OAUTH_REFRESH_TOKEN_ENV, config.getString("ytoauthrefreshtoken"));
114+
spotifyEnabled = getBooleanOverride(SPOTIFY_ENABLED_ENV, config.getBoolean("spotifyenabled"));
115+
spotifyApiUrl = getEnvOverride(SPOTIFY_API_URL_ENV, config.getString("spotifyapiurl"));
116+
spotifyPipePath = getEnvOverride(SPOTIFY_PIPE_PATH_ENV, config.getString("spotifypipepath"));
117+
spotifyFfmpegPath = getEnvOverride(SPOTIFY_FFMPEG_PATH_ENV, config.getString("spotifyffmpegpath"));
109118
transforms = config.getConfig("transforms");
110119
skipratio = config.getDouble("skipratio");
111120
dbots = owner == 113156185389092864L;
@@ -373,6 +382,26 @@ public String getYtOauthRefreshToken()
373382
return ytOauthRefreshToken.equals("YT_OAUTH_REFRESH_TOKEN_HERE") ? null : ytOauthRefreshToken;
374383
}
375384

385+
public boolean useSpotify()
386+
{
387+
return spotifyEnabled;
388+
}
389+
390+
public String getSpotifyApiUrl()
391+
{
392+
return spotifyApiUrl;
393+
}
394+
395+
public String getSpotifyPipePath()
396+
{
397+
return spotifyPipePath;
398+
}
399+
400+
public String getSpotifyFfmpegPath()
401+
{
402+
return spotifyFfmpegPath;
403+
}
404+
376405
public boolean useEval()
377406
{
378407
return useEval;

src/main/java/com/jagrosh/jmusicbot/JMusicBot.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import net.dv8tion.jda.api.*;
3636
import net.dv8tion.jda.api.audio.AudioModuleConfig;
3737
import net.dv8tion.jda.api.entities.Activity;
38+
import net.dv8tion.jda.api.entities.Role;
3839
import net.dv8tion.jda.api.requests.GatewayIntent;
3940
import net.dv8tion.jda.api.utils.cache.CacheFlag;
4041
import net.dv8tion.jda.api.exceptions.ErrorResponseException;
@@ -197,6 +198,18 @@ private static CommandClient createCommandClient(BotConfig config, SettingsManag
197198
CommandClientBuilder cb = new CommandClientBuilder()
198199
.setPrefix(config.getPrefix())
199200
.setAlternativePrefix(config.getAltPrefix())
201+
.setPrefixFunction(event ->
202+
{
203+
if(!event.isFromGuild())
204+
return null;
205+
long selfId = event.getJDA().getSelfUser().getIdLong();
206+
return event.getGuild().getSelfMember().getRoles().stream()
207+
.filter(Role::isManaged)
208+
.filter(role -> role.getTags() != null && role.getTags().isBot() && role.getTags().getBotIdLong() == selfId)
209+
.findFirst()
210+
.map(role -> "<@&" + role.getId() + ">")
211+
.orElse(null);
212+
})
200213
.setOwnerId(Long.toString(config.getOwnerId()))
201214
.setEmojis(config.getSuccess(), config.getWarning(), config.getError())
202215
.setHelpWord(config.getHelp())

0 commit comments

Comments
 (0)