diff --git a/.gitignore b/.gitignore index fc25521ac..1d1c8aee6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ nb*.xml /.cursor/ dependency-reduced-pom.xml *.lock +*.dll +*.so +*.dylib \ No newline at end of file diff --git a/pom.xml b/pom.xml index ca431babf..4814bca5a 100644 --- a/pom.xml +++ b/pom.xml @@ -316,6 +316,12 @@ com.fasterxml.jackson.core jackson-databind + + + net.java.dev.jna + jna + 5.13.0 + diff --git a/src/main/java/com/jagrosh/jmusicbot/BotConfig.java b/src/main/java/com/jagrosh/jmusicbot/BotConfig.java index 76eac9c0a..277743f05 100644 --- a/src/main/java/com/jagrosh/jmusicbot/BotConfig.java +++ b/src/main/java/com/jagrosh/jmusicbot/BotConfig.java @@ -58,7 +58,7 @@ public class BotConfig { private String token, prefix, altprefix, helpWord, playlistsFolder, logLevel, successEmoji, warningEmoji, errorEmoji, loadingEmoji, searchingEmoji, evalEngine; - private boolean stayInChannel, songInGame, npImages, updatealerts, useEval, dbots, useYouTubeOauth; + private boolean stayInChannel, songInGame, npImages, updatealerts, useEval, dbots, useYouTubeOauth, generatePOT; private long owner, maxSeconds, aloneTimeUntilStop; private int maxYTPlaylistPages; private double skipratio; @@ -259,6 +259,7 @@ private void loadConfigValues(Config config, Config migratedUserConfig) { maxSeconds = MAX_SECONDS.getLong(config); maxYTPlaylistPages = MAX_YT_PLAYLIST_PAGES.getInt(config); useYouTubeOauth = USE_YOUTUBE_OAUTH.getBoolean(config); + generatePOT = GENERATE_POT.getBoolean(config); aloneTimeUntilStop = ALONE_TIME_UNTIL_STOP.getLong(config); playlistsFolder = PLAYLISTS_FOLDER.getString(config); aliases = ALIASES.getConfig(config); @@ -456,6 +457,10 @@ public boolean useYouTubeOauth() { return useYouTubeOauth; } + public boolean generatePOT() { + return generatePOT; + } + public String getMaxTime() { return TimeUtil.formatTime(maxSeconds * 1000); } diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/AudioSource.java b/src/main/java/com/jagrosh/jmusicbot/audio/AudioSource.java index b3191b746..dee234e44 100644 --- a/src/main/java/com/jagrosh/jmusicbot/audio/AudioSource.java +++ b/src/main/java/com/jagrosh/jmusicbot/audio/AudioSource.java @@ -19,9 +19,11 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.util.Arrays; +import java.util.Objects; import java.util.Optional; import java.util.function.BiConsumer; +import com.jagrosh.jmusicbot.utils.BgUtil; import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry; import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers; @@ -34,6 +36,7 @@ import com.sedmelluq.discord.lavaplayer.source.twitch.TwitchStreamAudioSourceManager; import com.sedmelluq.discord.lavaplayer.source.vimeo.VimeoAudioSourceManager; import dev.lavalink.youtube.YoutubeAudioSourceManager; +import dev.lavalink.youtube.YoutubeSource; import dev.lavalink.youtube.YoutubeSourceOptions; import dev.lavalink.youtube.clients.AndroidVr; import dev.lavalink.youtube.clients.ClientOptions; @@ -42,6 +45,7 @@ import dev.lavalink.youtube.clients.TvHtml5Embedded; import dev.lavalink.youtube.clients.Web; import dev.lavalink.youtube.clients.skeleton.Client; +import dev.lavalink.youtube.http.YoutubeAccessTokenTracker; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -75,6 +79,7 @@ public enum AudioSource (manager, config) -> { YoutubeAudioSourceManager yt = setupYoutubeAudioSourceManager( config.useYouTubeOauth(), + config.generatePOT(), config.getMaxYTPlaylistPages() ); manager.registerSourceManager(yt); @@ -228,10 +233,11 @@ public void register(DefaultAudioPlayerManager manager, BotConfig config) * Sets up and configures a YouTube audio source manager. * * @param useOauth whether to use OAuth2 authentication + * @param generatePOT whether to use a POT provider * @param maxYTPlaylistPages maximum number of playlist pages to load * @return the configured YouTube audio source manager */ - private static YoutubeAudioSourceManager setupYoutubeAudioSourceManager(boolean useOauth, int maxYTPlaylistPages) + private static YoutubeAudioSourceManager setupYoutubeAudioSourceManager(boolean useOauth, boolean generatePOT, int maxYTPlaylistPages) { final Logger logger = LoggerFactory.getLogger(AudioSource.class); @@ -245,6 +251,33 @@ private static YoutubeAudioSourceManager setupYoutubeAudioSourceManager(boolean { applyOAuth(yt, logger); } + + if (generatePOT) + { + yt.getContextFilter().setTokenTracker(new YoutubeAccessTokenTracker(yt.getHttpInterfaceManager()) { + private static final Logger log = LoggerFactory.getLogger(YoutubeAccessTokenTracker.class); + public String lastVisitorId; + + @Override + public String getVisitorId() { + String visitorId = super.getVisitorId(); + if (!Objects.equals(visitorId, lastVisitorId)) { + lastVisitorId = visitorId; + if (BgUtil.isPresent()) { + BgUtil.PotResult potResult = BgUtil.parsePOT(BgUtil.generateJson(visitorId)); + if (potResult.poToken() != null) { + YoutubeSource.setPoTokenAndVisitorData(potResult.poToken(), potResult.contentBinding()); + log.info("PoToken generated: {}", potResult.poToken()); + } + } else { + log.warn("Couldn't use POT provider. The library may be missing."); + } + } + return visitorId; + } + }); + } + return yt; } diff --git a/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java b/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java index ccacbada5..727beac84 100644 --- a/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java +++ b/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java @@ -54,6 +54,7 @@ public enum ConfigOption { UPDATE_ALERTS("updates.alerts", ConfigType.BOOLEAN, false, "Whether to alert owner about updates"), USE_EVAL("dangerous.eval", ConfigType.BOOLEAN, false, "Whether to enable eval command (DANGEROUS)"), USE_YOUTUBE_OAUTH("playback.youtube.useOAuth", ConfigType.BOOLEAN, false, "Whether to use YouTube OAuth2 for playback"), + GENERATE_POT("playback.youtube.generatePOT", ConfigType.BOOLEAN, false, "Whether to generate PoToken through BGUtil"), // Numeric options MAX_SECONDS("playback.maxTrackSeconds", ConfigType.LONG, false, "Maximum track length in seconds (0 = no limit)"), diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/BgUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/BgUtil.java new file mode 100644 index 000000000..a53e32dff --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/BgUtil.java @@ -0,0 +1,91 @@ +package com.jagrosh.jmusicbot.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; + +import java.nio.file.Files; +import java.nio.file.Paths; + +/** + * JNA binding for BgUtils POT Provider. + */ +public interface BgUtil extends Library { + Pointer ffi_generate(String content_binding, String proxy, boolean bypass_cache, String source_address, boolean disable_tls); + void ffi_free_string(Pointer ptr); + + class Factory { + static BgUtil INSTANCE; + static Boolean isPresent; + + private static boolean tryInstantiate(String path) { + try { + if (Files.exists(Paths.get(path))) { + INSTANCE = Native.load(path, BgUtil.class); + isPresent = true; + return false; + } + } catch (Exception _) {} + return true; + } + + public static BgUtil getInstance() { + if (INSTANCE == null && isPresent == null) { + isPresent = false; + + String libbase = "./libbgutil_ytdlp_pot_provider"; + String libext = "-linux-.so"; + String osname = System.getProperty("os.name").toLowerCase(); + if (osname.contains("win")) { + libbase = "./bgutil_ytdlp_pot_provider"; + libext = "-windows-.dll"; + } else if (osname.contains("mac")) { + libext = "-macos-.dylib"; + } + + String libpath = libbase + libext.replace(".", System.getProperty("os.arch") + "."); + if (tryInstantiate(libpath)) { + // x86_64 fallback + libpath = libbase + libext.replace(".", "x86_64."); + if (tryInstantiate(libpath)) { + // generic fallback + libpath = libbase + libext.substring(libext.indexOf(".")); + tryInstantiate(libpath); + } + } + } + return INSTANCE; + } + } + + static boolean isPresent() { + Factory.getInstance(); + return Boolean.TRUE.equals(Factory.isPresent); + } + + static String generateJson(String content_binder) { + Pointer ptr = Factory.getInstance().ffi_generate(content_binder, null, false, null, false); + String result = ptr.getString(0); + Factory.getInstance().ffi_free_string(ptr); + return result; + } + + static PotResult parsePOT(String json) { + PotResult result = PotResult.NULL; + try { + JsonNode node = PotResult.mapper.readTree(json); + if (!node.isEmpty()) { + result = new PotResult(node.get("poToken").asText(), node.get("contentBinding").asText(), node.get("expiresAt").asText()); + } + } catch (JsonProcessingException _) {} + return result; + } + + record PotResult(String poToken, String contentBinding, String expiresAt) { + public static final PotResult NULL = new PotResult(null, null, "1970-01-01T00:00:00.000000000Z"); + public static final ObjectMapper mapper = new ObjectMapper(); + } +} diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf index c99e1e667..31eee5341 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -45,6 +45,11 @@ playback { # When enabled, a code is printed in console/DM'd to the owner for https://www.google.com/device # Only needs to be done once (refresh token persists unless changed). useOAuth = false + + # Alternative to YouTube OAuth to avoid "Video unavailable" issues. + # When enabled, the bot searches for a bgutil_pot_provider-{os}-{arch}.dll/.so/.dylib file to generate a PoToken. + # The file search always fallbacks to bgutil_ytdlp_pot_provider.dll/.so/.dylib + generatePOT = false } # Audio sources toggles. diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java index 2902be3f1..699e5eebc 100644 --- a/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java +++ b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java @@ -178,7 +178,7 @@ void oauthFlowTriggeredWhenUseOauthIsTrue() throws Exception { // but we can verify the method completes without throwing assertDoesNotThrow(() -> { YoutubeAudioSourceManager result = (YoutubeAudioSourceManager) - setupMethod.invoke(null, true, 10); + setupMethod.invoke(null, true, false, 10); assertNotNull(result, "Should return a configured YoutubeAudioSourceManager"); }, "setupYoutubeAudioSourceManager should complete without throwing when OAuth is enabled"); } @@ -191,7 +191,7 @@ void oauthFlowNotTriggeredWhenUseOauthIsFalse() throws Exception { // Call with useOauth=false - this should NOT trigger OAuth assertDoesNotThrow(() -> { YoutubeAudioSourceManager result = (YoutubeAudioSourceManager) - setupMethod.invoke(null, false, 10); + setupMethod.invoke(null, false, false, 10); assertNotNull(result, "Should return a configured YoutubeAudioSourceManager"); }, "setupYoutubeAudioSourceManager should complete without OAuth when disabled"); } @@ -199,7 +199,7 @@ void oauthFlowNotTriggeredWhenUseOauthIsFalse() throws Exception { private Method getSetupYoutubeAudioSourceManagerMethod() throws NoSuchMethodException { Class audioSourceClass = com.jagrosh.jmusicbot.audio.AudioSource.class; Method method = audioSourceClass.getDeclaredMethod("setupYoutubeAudioSourceManager", - boolean.class, int.class); + boolean.class, boolean.class, int.class); method.setAccessible(true); return method; }