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..ff8e74d 100644 --- a/README.md +++ b/README.md @@ -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). 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..cfd786e 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; @@ -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 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..840365b --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/YoutubeConfiguration.java @@ -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 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(); + } +}