From 06551adbf509a44eba400c70fecd2130ae1d6596 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:17:52 -0700 Subject: [PATCH 1/5] Support independent YouTube OAuth and poToken client fallbacks --- .env.example | 6 ++ README.md | 13 +++- compose.yaml | 3 + .../infinitybot/AudioManager.java | 11 +--- .../theinfinitymc/infinitybot/GuildAudio.java | 23 ++++++- .../infinitybot/InfinityBot.java | 7 +- .../infinitybot/YoutubeConfiguration.java | 64 +++++++++++++++++++ 7 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java diff --git a/.env.example b/.env.example index 96f9bed..4afde4f 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 3f118f2..6e29afe 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,19 @@ 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). Use a burner account, not your primary account: upstream warns of account-termination risk. 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). An incomplete pair fails startup before connecting to Discord. After changing `.env`, run `docker compose up -d` to recreate the bot with the new values. + +Startup logs show enabled authentication modes and client order without credential values. Playback failures/stalls produce a short guild-scoped warning; a failed Discord notification does not prevent queue advancement. There are no health probes or aggregate failure alerts. Avoid enabling upstream DEBUG logging or sharing unredacted upstream errors: these may contain credentials or signed playback URLs. 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). diff --git a/compose.yaml b/compose.yaml index 02a3aec..8ff8074 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java b/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java index aff08f0..cc9991a 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java +++ b/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java @@ -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; @@ -23,18 +21,11 @@ public class AudioManager { private final Map guildAudioMap; AudioManager(){ + YoutubeAudioSourceManager youtubeSource = YoutubeConfiguration.createSource(); this.guildAudioMap = new HashMap<>(); 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()); audioPlayerManager.registerSourceManager(youtubeSource); @SuppressWarnings("deprecation") Class deprecatedYoutubeSource = com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager.class; AudioSourceManagers.registerRemoteSources(audioPlayerManager, deprecatedYoutubeSource); diff --git a/src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java b/src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java index c47c558..d7e8bef 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java +++ b/src/main/java/net/theinfinitymc/infinitybot/GuildAudio.java @@ -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; @@ -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 queue; @@ -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(); @@ -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()); + } + } } diff --git a/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java b/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java index 8c2acb6..a76df74 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java +++ b/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java @@ -25,6 +25,8 @@ 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)) @@ -32,13 +34,10 @@ public static void main(String[] args) { .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); } } diff --git a/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java new file mode 100644 index 0000000..a1832bd --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java @@ -0,0 +1,64 @@ +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"); + + if ((poToken == null) != (visitorData == null)) { + throw new IllegalArgumentException("YOUTUBE_PO_TOKEN and YOUTUBE_VISITOR_DATA must both be set or both be empty."); + } + + YoutubeSourceOptions options = new YoutubeSourceOptions(); + if (cipherUrl != null) { + options.setRemoteCipher(cipherUrl, cipherPassword, "InfinityBot"); + } + // Configure both clients directly: YoutubeSource's convenience helper logs token values at DEBUG. + Web.setPoTokenAndVisitorData(poToken, visitorData); + WebEmbedded.setPoTokenAndVisitorData(poToken, visitorData); + + List 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(); + } +} From 1ce6bf13903c7d19ea3c3077b84c44042c02a8a3 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:59:38 -0700 Subject: [PATCH 2/5] Update AudioManager.java --- src/main/java/net/theinfinitymc/infinitybot/AudioManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java b/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java index cc9991a..cfd786e 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java +++ b/src/main/java/net/theinfinitymc/infinitybot/AudioManager.java @@ -21,11 +21,11 @@ public class AudioManager { private final Map guildAudioMap; AudioManager(){ - YoutubeAudioSourceManager youtubeSource = YoutubeConfiguration.createSource(); this.guildAudioMap = new HashMap<>(); this.audioPlayerManager = new DefaultAudioPlayerManager(); // Register default sources, but replace the deprecated YT source with new version + YoutubeAudioSourceManager youtubeSource = YoutubeConfiguration.createSource(); audioPlayerManager.registerSourceManager(youtubeSource); @SuppressWarnings("deprecation") Class deprecatedYoutubeSource = com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager.class; AudioSourceManagers.registerRemoteSources(audioPlayerManager, deprecatedYoutubeSource); From 9cb5643311a0a6739dbc0edd11a386d7418c6dc4 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:03:42 -0700 Subject: [PATCH 3/5] Update YoutubeConfiguration.java --- .../net/theinfinitymc/infinitybot/YoutubeConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java index a1832bd..81d5065 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java +++ b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java @@ -31,7 +31,7 @@ static YoutubeAudioSourceManager createSource() { if (cipherUrl != null) { options.setRemoteCipher(cipherUrl, cipherPassword, "InfinityBot"); } - // Configure both clients directly: YoutubeSource's convenience helper logs token values at DEBUG. + Web.setPoTokenAndVisitorData(poToken, visitorData); WebEmbedded.setPoTokenAndVisitorData(poToken, visitorData); From 16012fa25652c67eca51c3daac821f271b50a9e6 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:10:45 -0700 Subject: [PATCH 4/5] Update YoutubeConfiguration.java --- .../theinfinitymc/infinitybot/YoutubeConfiguration.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java index 81d5065..840365b 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java +++ b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java @@ -23,15 +23,14 @@ static YoutubeAudioSourceManager createSource() { String poToken = environment("YOUTUBE_PO_TOKEN"); String visitorData = environment("YOUTUBE_VISITOR_DATA"); - if ((poToken == null) != (visitorData == null)) { - throw new IllegalArgumentException("YOUTUBE_PO_TOKEN and YOUTUBE_VISITOR_DATA must both be set or both be empty."); - } - 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); @@ -40,6 +39,7 @@ static YoutubeAudioSourceManager createSource() { if (refreshToken != null) { clients.add(new Tv()); } + YoutubeAudioSourceManager source = new YoutubeAudioSourceManager(options, clients.toArray(Client[]::new)); if (refreshToken != null) { try { @@ -51,6 +51,7 @@ static YoutubeAudioSourceManager createSource() { 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(", "))); From 56d1aeb30e6b8225d4ef05625a74afa8ce590ebd Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:12:47 -0700 Subject: [PATCH 5/5] Simplify OAuth and poToken instructions in README --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6e29afe..ff8e74d 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,9 @@ Blank values are treated as unset. Cipher, OAuth, and poToken are independent an 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). Use a burner account, not your primary account: upstream warns of account-termination risk. The bot does not initiate an interactive login flow. If configured OAuth cannot initialize, startup fails rather than silently disabling it. +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). An incomplete pair fails startup before connecting to Discord. After changing `.env`, run `docker compose up -d` to recreate the bot with the new values. - -Startup logs show enabled authentication modes and client order without credential values. Playback failures/stalls produce a short guild-scoped warning; a failed Discord notification does not prevent queue advancement. There are no health probes or aggregate failure alerts. Avoid enabling upstream DEBUG logging or sharing unredacted upstream errors: these may contain credentials or signed playback URLs. +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).