Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
YOUTUBE_REMOTE_CIPHER_PASSWORD=replace-with-a-long-random-secret

# Optional: refresh token from a burner YouTube account (not an access token).
YOUTUBE_OAUTH_REFRESH_TOKEN=
# Optional: matching pair; set both or leave both blank.
YOUTUBE_PO_TOKEN=
YOUTUBE_VISITOR_DATA=
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,17 @@ Images: `ghcr.io/nextinfinity/infinitybot:main` (development), release tags, `sh
| `DISCORD_BOT_TOKEN` | Required Discord bot token. |
| `YOUTUBE_REMOTE_CIPHER_URL` | Cipher base URL; Compose sets `http://yt-cipher:8001`. |
| `YOUTUBE_REMOTE_CIPHER_PASSWORD` | Optional cipher API password; required by the provided Compose stack. |
| `YOUTUBE_OAUTH_REFRESH_TOKEN` | Optional YouTube OAuth **refresh token**; enables the TV playback fallback. |
| `YOUTUBE_PO_TOKEN` | Optional proof-of-origin token for Web clients; requires matching visitor data. |
| `YOUTUBE_VISITOR_DATA` | Companion to `YOUTUBE_PO_TOKEN`; configure both or neither. |

Without remote cipher configured, the bot uses local deciphering.
Blank values are treated as unset. Cipher, OAuth, and poToken are independent and can be enabled together. Without remote cipher configured, the bot uses local deciphering.

Clients are tried in this order: Music (search), Android VR, Web, Web Embedded, and TV (only with OAuth configured). OAuth applies to TV playback, while poToken is applied to Web and Web Embedded; configuring both broadens fallback coverage rather than combining credentials on the same client.

OAuth access tokens are refreshed automatically by youtube-source. Initial authorization and replacement of a revoked refresh token remain manual; see [upstream OAuth instructions](https://github.com/lavalink-devs/youtube-source#using-oauth-tokens). The bot does not initiate an interactive login flow. If configured OAuth cannot initialize, startup fails rather than silently disabling it.

poToken/visitor-data pairs are supplied manually and are not automatically generated or renewed. See [upstream poToken instructions](https://github.com/lavalink-devs/youtube-source#using-a-potoken).

Cipher support solves signature deciphering, **not** YouTube IP blocks, age restrictions, or all sign-in challenges. Test playback on the intended deployment host. See [youtube-source remote cipher documentation](https://github.com/lavalink-devs/youtube-source#using-a-remote-cipher-server).

Expand Down
3 changes: 3 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ services:
environment:
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}
YOUTUBE_REMOTE_CIPHER_URL: http://yt-cipher:8001
YOUTUBE_OAUTH_REFRESH_TOKEN: ${YOUTUBE_OAUTH_REFRESH_TOKEN:-}
YOUTUBE_PO_TOKEN: ${YOUTUBE_PO_TOKEN:-}
YOUTUBE_VISITOR_DATA: ${YOUTUBE_VISITOR_DATA:-}
YOUTUBE_REMOTE_CIPHER_PASSWORD: ${YOUTUBE_REMOTE_CIPHER_PASSWORD:?Set YOUTUBE_REMOTE_CIPHER_PASSWORD in .env}
depends_on:
- yt-cipher
Expand Down
11 changes: 1 addition & 10 deletions src/main/java/net/theinfinitymc/infinitybot/AudioManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.YoutubeSourceOptions;
import dev.lavalink.youtube.clients.*;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.User;
Expand All @@ -27,14 +25,7 @@ public class AudioManager {
this.audioPlayerManager = new DefaultAudioPlayerManager();

// Register default sources, but replace the deprecated YT source with new version
YoutubeSourceOptions youtubeOptions = new YoutubeSourceOptions();
String cipherUrl = System.getenv("YOUTUBE_REMOTE_CIPHER_URL");
if (cipherUrl != null && !cipherUrl.isBlank()) {
youtubeOptions.setRemoteCipher(cipherUrl,
System.getenv("YOUTUBE_REMOTE_CIPHER_PASSWORD"), "InfinityBot");
}
YoutubeAudioSourceManager youtubeSource = new YoutubeAudioSourceManager(youtubeOptions,
new WebWithThumbnail(), new WebEmbeddedWithThumbnail());
YoutubeAudioSourceManager youtubeSource = YoutubeConfiguration.createSource();
audioPlayerManager.registerSourceManager(youtubeSource);
@SuppressWarnings("deprecation") Class<? extends AudioSourceManager> deprecatedYoutubeSource = com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager.class;
AudioSourceManagers.registerRemoteSources(audioPlayerManager, deprecatedYoutubeSource);
Expand Down
23 changes: 21 additions & 2 deletions src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
import net.theinfinitymc.infinitybot.commands.Pause;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.awt.*;
import java.time.Instant;
Expand All @@ -21,6 +23,7 @@
@Value
@EqualsAndHashCode(callSuper=false)
public class GuildAudio extends AudioEventAdapter {
private static final Logger log = LoggerFactory.getLogger(GuildAudio.class);
Guild guild;
AudioPlayer player;
BlockingQueue<AudioTrack> queue;
Expand Down Expand Up @@ -125,7 +128,8 @@ public void onTrackStart(AudioPlayer player, AudioTrack track) {
@Override
public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) {
if (endReason == AudioTrackEndReason.LOAD_FAILED) {
((Message) track.getUserData()).reply("Unable to play song - proceeding to next available.").queue();
log.warn("Playback failed in guild {}; advancing the queue.", guild.getId());
notifyFailure(track, "Unable to play song - proceeding to next available.");
}
if (endReason.mayStartNext) {
playNext();
Expand All @@ -136,7 +140,22 @@ public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason

@Override
public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) {
((Message) track.getUserData()).reply("No audio detected, skipping track.").queue();
log.warn("Playback stuck in guild {}; advancing the queue.", guild.getId());
notifyFailure(track, "No audio detected, skipping track.");
playNext();
}

private void notifyFailure(AudioTrack track, String text) {
try {
Object data = track.getUserData();
if (data instanceof Message message) {
message.reply(text).queue(null, failure -> log.warn("Could not send playback failure notice in guild {}.", guild.getId()));
} else if (data instanceof GuildTrackData trackData) {
trackData.getChannel().sendMessage(text).queue(null, failure -> log.warn("Could not send playback failure notice in guild {}.", guild.getId()));
}
} catch (RuntimeException exception) {
// A missing message or permission must not prevent queue advancement.
log.warn("Could not send playback failure notice in guild {}.", guild.getId());
}
}
}
7 changes: 3 additions & 4 deletions src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,19 @@ public static void main(String[] args) {

InfinityBot() {
try {
// Validate YouTube configuration before opening the Discord connection.
this.audioManager = new AudioManager();
CommandListener listener = new CommandListener();
this.jda = JDABuilder.createDefault(System.getenv("DISCORD_BOT_TOKEN"),
Collections.singletonList(GatewayIntent.GUILD_VOICE_STATES))
.setAudioModuleConfig(new AudioModuleConfig()
.withDaveSessionFactory(new JDaveSessionFactory()))
.addEventListeners(listener)
.build();
this.audioManager = new AudioManager();
listener.registerCommands(jda, audioManager);
updateActivity();
} catch (Exception exception) {
InstantiationError error = new InstantiationError("Failed to load InfinityBot.");
error.setStackTrace(exception.getStackTrace());
throw error;
throw new IllegalStateException("Failed to load InfinityBot.", exception);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package net.theinfinitymc.infinitybot;

import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.YoutubeSourceOptions;
import dev.lavalink.youtube.clients.*;
import dev.lavalink.youtube.clients.skeleton.Client;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

final class YoutubeConfiguration {
private static final Logger log = LoggerFactory.getLogger(YoutubeConfiguration.class);

private YoutubeConfiguration() {}

static YoutubeAudioSourceManager createSource() {
String cipherUrl = environment("YOUTUBE_REMOTE_CIPHER_URL");
String cipherPassword = environment("YOUTUBE_REMOTE_CIPHER_PASSWORD");
String refreshToken = environment("YOUTUBE_OAUTH_REFRESH_TOKEN");
String poToken = environment("YOUTUBE_PO_TOKEN");
String visitorData = environment("YOUTUBE_VISITOR_DATA");

YoutubeSourceOptions options = new YoutubeSourceOptions();
if (cipherUrl != null) {
options.setRemoteCipher(cipherUrl, cipherPassword, "InfinityBot");
}

if ((poToken == null) != (visitorData == null)) {
throw new IllegalArgumentException("YOUTUBE_PO_TOKEN and YOUTUBE_VISITOR_DATA must both be set or both be empty.");
}
Web.setPoTokenAndVisitorData(poToken, visitorData);
WebEmbedded.setPoTokenAndVisitorData(poToken, visitorData);

List<Client> clients = new ArrayList<>(List.of(new MusicWithThumbnail(),
new AndroidVrWithThumbnail(), new WebWithThumbnail(), new WebEmbeddedWithThumbnail()));
if (refreshToken != null) {
clients.add(new Tv());
}

YoutubeAudioSourceManager source = new YoutubeAudioSourceManager(options, clients.toArray(Client[]::new));
if (refreshToken != null) {
try {
// Refresh access tokens automatically; never start an interactive device-login flow.
source.useOauth2(refreshToken, true);
} catch (RuntimeException exception) {
source.shutdown();
// Do not include the upstream exception: authentication responses can contain secrets.
throw new IllegalStateException("YouTube OAuth initialization failed. Check YOUTUBE_OAUTH_REFRESH_TOKEN and network access.");
}
}

log.info("YouTube configured: remoteCipher={}, oauth={}, poToken={}, clients=[{}]",
cipherUrl != null, refreshToken != null, poToken != null,
clients.stream().map(Client::getIdentifier).collect(Collectors.joining(", ")));
return source;
}

private static String environment(String name) {
String value = System.getenv(name);
return value == null || value.isBlank() ? null : value.strip();
}
}
Loading