Compare commits

...
5 changed files with 309 additions and 82 deletions

No files matched your search

+11 -9
View File
@@ -6,6 +6,8 @@ A self-hosted Discord music bot fork based on JMusicBot, updated to keep working
This repository keeps the bot practical to run today, while also adding fork-specific features such as status messages and playback analytics logging. Possibly more.
Also, we somewhat regularly bump [lavaplayer](https://mvnrepository.com/artifact/dev.arbjerg/lavaplayer)/[lavalink](https://maven.lavalink.dev/#/releases/dev/lavalink/youtube/common) to their new versions. I will try to update this automatically in the future.
## Demo
Temporary invite: [discord.gg/4cp9CvjwaW](https://discord.gg/4cp9CvjwaW)
@@ -25,9 +27,9 @@ Permanent invite: [discord.gg/hK95396uB3](https://discord.gg/hK95396uB3)
## Quick Links
- Fork changes and version notes: [docs/fork-changelog.md](/git/MusicBot/docs/fork-changelog.md)
- Setup guide: [docs/setup.md](/git/MusicBot/docs/setup.md)
- Docker usage: [docs/docker.md](/git/MusicBot/docs/docker.md)
- Fork changes and version notes: [docs/fork-changelog.md](/docs/fork-changelog.md)
- Setup guide: [docs/setup.md](/docs/setup.md)
- Docker usage: [docs/docker.md](/docs/docker.md)
## Features
@@ -70,13 +72,13 @@ If you only need the essentials:
3. Configure `config.txt`.
4. Run the jar directly or use Docker.
For the detailed version, see [docs/setup.md](/git/MusicBot/docs/setup.md).
For the detailed version, see [docs/setup.md](/docs/setup.md).
## Docker
A Docker image is published here: [docker.io/chrisb09/jmusicbot](https://hub.docker.com/r/chrisb09/jmusicbot)
For compose examples and deployment notes, see [docs/docker.md](/git/MusicBot/docs/docker.md).
For compose examples and deployment notes, see [docs/docker.md](/docs/docker.md).
## YouTube Login
@@ -90,15 +92,15 @@ youtubeoauth2=true
Do not use your main Google account. Use a burner or otherwise disposable account instead.
More details are in [docs/setup.md](/git/MusicBot/docs/setup.md).
More details are in [docs/setup.md](/docs/setup.md).
If you use PO tokens or visitor data for YouTube, those details are also covered in [docs/setup.md](/git/MusicBot/docs/setup.md).
If you use PO tokens or visitor data for YouTube, those details are also covered in [docs/setup.md](/docs/setup.md).
## DAVE / Voice Encryption
Discord currently requires DAVE for voice on modern API versions. This fork configures `libdave-jvm` during startup and bundles native binaries for common Linux, Windows, and macOS targets.
If you deploy on an unsupported architecture, add the matching native dependency in [pom.xml](/git/MusicBot/pom.xml). You probably need to build the library from source for your architecture, so have fun with that.
If you deploy on an unsupported architecture, add the matching native dependency in [pom.xml](/pom.xml). You probably need to build the library from source for your architecture, so have fun with that.
## Upstream And Related Work
@@ -119,4 +121,4 @@ If you run into issues, please include:
- relevant config changes
- relevant logs
Fork-specific version notes live in [docs/fork-changelog.md](/git/MusicBot/docs/fork-changelog.md), which should help with troubleshooting and migration as the fork evolves.
Fork-specific version notes live in [docs/fork-changelog.md](/docs/fork-changelog.md), which should help with troubleshooting and migration as the fork evolves.
+4 -4
View File
@@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.jagrosh</groupId>
<artifactId>JMusicBot</artifactId>
<version>0.6.6</version>
<version>0.6.7</version>
<packaging>jar</packaging>
<repositories>
@@ -151,7 +151,7 @@
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavaplayer</artifactId>
<version>2.2.4</version>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>dev.arbjerg</groupId>
@@ -161,7 +161,7 @@
<dependency>
<groupId>dev.lavalink.youtube</groupId>
<artifactId>common</artifactId>
<version>1.18.1</version>
<version>f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.github.jagrosh</groupId>
@@ -203,7 +203,7 @@
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.27.0</version>
<version>4.47.0</version>
<!-- https://github.com/SeleniumHQ/selenium/issues/13791 -->
<exclusions>
<exclusion>
+1 -1
View File
@@ -176,7 +176,7 @@ public class Bot
return;
shuttingDown = true;
threadpool.shutdownNow();
if(jda.getStatus()!=JDA.Status.SHUTTING_DOWN)
if(jda != null && jda.getStatus()!=JDA.Status.SHUTTING_DOWN)
{
jda.getGuilds().stream().forEach(g ->
{
@@ -40,7 +40,17 @@ import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.IpBlock;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv4Block;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv6Block;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.clients.Android;
import dev.lavalink.youtube.clients.AndroidMusic;
import dev.lavalink.youtube.clients.AndroidVr;
import dev.lavalink.youtube.clients.ClientOptions;
import dev.lavalink.youtube.clients.Ios;
import dev.lavalink.youtube.clients.MWeb;
import dev.lavalink.youtube.clients.Music;
import dev.lavalink.youtube.clients.Tv;
import dev.lavalink.youtube.clients.TvHtml5Simply;
import dev.lavalink.youtube.clients.Web;
import dev.lavalink.youtube.clients.WebEmbedded;
import net.dv8tion.jda.api.entities.Guild;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -68,7 +78,7 @@ public class PlayerManager extends DefaultAudioPlayerManager
{
TransformativeAudioSourceManager.createTransforms(bot.getConfig().getTransforms()).forEach(t -> registerSourceManager(t));
YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(true);
YoutubeAudioSourceManager yt = setupYoutubeAudioSourceManager();
if (config.getYTRoutingPlanner() != YouTubeUtil.RoutingPlanner.NONE)
{
AbstractRoutePlanner routePlanner = YouTubeUtil.createRouterPlanner(config.getYTRoutingPlanner(), config.getYTIpBlocks());
@@ -85,7 +95,15 @@ public class PlayerManager extends DefaultAudioPlayerManager
registerSourceManager(SoundCloudAudioSourceManager.createDefault());
registerSourceManager(new BandcampAudioSourceManager());
registerSourceManager(new VimeoAudioSourceManager());
registerSourceManager(new TwitchStreamAudioSourceManager());
try
{
registerSourceManager(new TwitchStreamAudioSourceManager());
}
catch(Exception e)
{
LOGGER.warn("Skipping Twitch source initialization because Twitch lookup failed during startup: {}", e.getMessage());
LOGGER.debug("Twitch source initialization failure:", e);
}
registerSourceManager(new BeamAudioSourceManager());
registerSourceManager(new GetyarnAudioSourceManager());
registerSourceManager(new NicoAudioSourceManager());
@@ -98,7 +116,20 @@ public class PlayerManager extends DefaultAudioPlayerManager
private YoutubeAudioSourceManager setupYoutubeAudioSourceManager()
{
YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(true);
ClientOptions playbackOnly = new ClientOptions();
playbackOnly.setSearching(false);
YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(true,
new Ios(playbackOnly),
new Music(playbackOnly),
new AndroidMusic(playbackOnly),
new TvHtml5Simply(playbackOnly),
new Tv(playbackOnly),
new Web(),
new MWeb(),
new WebEmbedded(),
new AndroidVr(playbackOnly),
new Android(playbackOnly));
yt.setPlaylistPageCount(bot.getConfig().getMaxYTPlaylistPages());
// OAuth2 setup
@@ -4,28 +4,37 @@ import com.sedmelluq.lava.extensions.youtuberotator.planner.*;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.IpBlock;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv4Block;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv6Block;
import com.sun.net.httpserver.HttpServer;
import dev.lavalink.youtube.clients.Web;
import dev.lavalink.youtube.clients.WebEmbedded;
import org.json.JSONObject;
import org.openqa.selenium.By;
import org.openqa.selenium.Cookie;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeDriverService;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.chromium.ChromiumDriverLogLevel;
import org.openqa.selenium.devtools.NetworkInterceptor;
import org.openqa.selenium.logging.LogEntries;
import org.openqa.selenium.logging.LogEntry;
import org.openqa.selenium.logging.LogType;
import org.openqa.selenium.logging.LoggingPreferences;
import org.openqa.selenium.os.ExecutableFinder;
import org.openqa.selenium.remote.http.Contents;
import org.openqa.selenium.remote.http.Filter;
import org.openqa.selenium.remote.http.HttpMethod;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileWriter;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;
import io.github.bonigarcia.wdm.WebDriverManager;
@@ -105,6 +114,21 @@ public class YouTubeUtil {
chromeOptions.setBinary(userChromePath != null ? userChromePath : wdm.getBrowserPath().get().toString());
// Essential flags for headless/containerized environments (Docker)
chromeOptions.addArguments("--no-sandbox");
chromeOptions.addArguments("--disable-dev-shm-usage");
chromeOptions.addArguments("--disable-gpu");
chromeOptions.addArguments("--window-size=1920,1080");
chromeOptions.addArguments("--disable-blink-features=AutomationControlled");
chromeOptions.addArguments("--autoplay-policy=no-user-gesture-required");
chromeOptions.addArguments("--disable-features=IsolateOrigins,site-per-process");
chromeOptions.addArguments("--disable-site-isolation-trials");
// Enable Chrome performance logging to capture Network.requestWillBeSent events
LoggingPreferences logPrefs = new LoggingPreferences();
logPrefs.enable(LogType.PERFORMANCE, Level.ALL);
chromeOptions.setCapability("goog:loggingPrefs", logPrefs);
if (!headless)
chromeOptions.addArguments("--auto-open-devtools-for-tabs");
else
@@ -117,77 +141,247 @@ public class YouTubeUtil {
WebDriver driver = new ChromeDriver(chromeDriverBuilder.build(), chromeOptions);
//
// 2. Setup a network interceptor to intercept the player API request & obtain po token and visitor data
//
// as the network interceptor below runs on a different thread, we'll have it notify us on this/current thread
// whether retrieving the data failed (completes with the Exception) or succeeded (completes with null)
CompletableFuture<Exception> interceptorFuture = new CompletableFuture<>();
NetworkInterceptor interceptor = new NetworkInterceptor(driver,
(Filter) next -> req -> {
if (req.getMethod() != HttpMethod.POST || !req.getUri().contains("/youtubei/v1/player"))
return next.execute(req);
try {
String rawBody = Contents.string(req);
JSONObject body = new JSONObject(rawBody);
String visitorData = body
.getJSONObject("context")
.getJSONObject("client")
.getString("visitorData");
String poToken = body
.getJSONObject("serviceIntegrityDimensions")
.getString("poToken");
LOGGER.info("Successfully retrieved PO token & visitor data for YouTube playback.");
LOGGER.debug("PO token: {}", poToken);
LOGGER.debug("Visitor Data: {}", visitorData);
if (poToken.length() < 160)
LOGGER.warn("There is a high chance that the retrieved PO token will not work. Try on a different network.");
Web.setPoTokenAndVisitorData(poToken, visitorData);
interceptorFuture.complete(null);
} catch (Exception e) {
interceptorFuture.complete(e);
}
return next.execute(req);
});
//
// 3. Navigate to YouTube & start the player
// 2. Start local HTTP server to host iframe embed & extract PO token from performance logs
//
HttpServer server = null;
try {
driver.get("https://www.youtube.com/embed/aqz-KE-bpKQ");
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(15));
WebElement moviePlayer = driver.findElement(By.cssSelector("#movie_player"));
moviePlayer.click();
// Start a lightweight local HTTP server to host the YouTube embed in an iframe.
// This provides a valid origin/Referer (http://127.0.0.1:PORT) so YouTube does not
// reject the embed with Error 153 "Video player configuration error".
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/embed", exchange -> {
String html = "<!DOCTYPE html>" +
"<html>" +
"<head>" +
"<meta name=\"referrer\" content=\"strict-origin-when-cross-origin\">" +
"<style>html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000;}" +
"iframe{width:100%;height:100%;border:none;}</style>" +
"</head>" +
"<body>" +
"<iframe id=\"player\" " +
"src=\"https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ?autoplay=1&mute=1&enablejsapi=1\" " +
"referrerpolicy=\"strict-origin-when-cross-origin\" " +
"allow=\"autoplay; encrypted-media; picture-in-picture\" " +
"allowfullscreen></iframe>" +
"</body>" +
"</html>";
byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8");
exchange.getResponseHeaders().set("Referrer-Policy", "strict-origin-when-cross-origin");
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
});
server.start();
int serverPort = server.getAddress().getPort();
// First, navigate to youtube.com to inject consent cookies to bypass EU/GDPR consent walls
try {
// Wait for the network interceptor to tell us if it got the data
Exception result = interceptorFuture.get(20, TimeUnit.SECONDS);
if (result != null)
throw new Exception("Failed to read YouTube player request body", result);
} catch (TimeoutException e) {
throw new Exception("Timed out waiting for YouTube player request");
driver.get("https://www.youtube.com");
driver.manage().addCookie(new Cookie.Builder("SOCS", "CAESEwgDEgk0ODE3Nzk3MjQaAmVuIAEaBgiA_LyaBg")
.domain(".youtube.com")
.path("/")
.build());
driver.manage().addCookie(new Cookie.Builder("CONSENT", "PENDING+999")
.domain(".youtube.com")
.path("/")
.build());
} catch (Exception e) {
LOGGER.debug("Could not pre-set consent cookies: {}", e.getMessage());
}
// Navigate to local embed wrapper page
driver.get("http://127.0.0.1:" + serverPort + "/embed");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Wait for iframe and switch to it to interact with player
try {
wait.until(ExpectedConditions.frameToBeAvailableAndSwitchToIt(By.id("player")));
// Dismiss any consent dialog inside the iframe if present
try {
List<WebElement> consentButtons = driver.findElements(By.xpath(
"//button[contains(@aria-label, 'Agree') or contains(@aria-label, 'Accept') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'agree') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'accept') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'alle akzeptieren') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ich stimme zu') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'tout accepter') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'accepter tout') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'accetta tutto') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'aceptar todo')]"
));
for (WebElement btn : consentButtons) {
if (btn.isDisplayed()) {
LOGGER.info("Dismissing consent dialog inside iframe...");
btn.click();
Thread.sleep(1000);
break;
}
}
} catch (Exception ignored) {}
// Try to click player inside iframe to trigger playback and /player request
try {
WebElement moviePlayer = wait.until(ExpectedConditions.presenceOfElementLocated(
By.cssSelector("#movie_player, .ytp-large-play-button, .html5-video-player, video")
));
moviePlayer.click();
} catch (Exception ignored) {
((JavascriptExecutor) driver).executeScript(
"var p = document.getElementById('movie_player') || document.querySelector('video'); " +
"if (p) { if (p.playVideo) p.playVideo(); else if (p.play) p.play(); }"
);
}
// Switch back to default context so performance logging and main window operations work
driver.switchTo().defaultContent();
} catch (Exception e) {
LOGGER.debug("Could not interact with embed iframe: {}", e.getMessage());
try {
driver.switchTo().defaultContent();
} catch (Exception ignored) {}
}
// Poll performance logs to capture the /youtubei/v1/player request
boolean success = false;
long deadline = System.currentTimeMillis() + 25_000;
long nextActionTime = System.currentTimeMillis() + 5_000;
while (System.currentTimeMillis() < deadline && !success) {
LogEntries logEntries = driver.manage().logs().get(LogType.PERFORMANCE);
for (LogEntry entry : logEntries) {
try {
JSONObject json = new JSONObject(entry.getMessage());
JSONObject message = json.optJSONObject("message");
if (message == null) continue;
String method = message.optString("method");
if ("Network.requestWillBeSent".equals(method)) {
JSONObject params = message.optJSONObject("params");
if (params == null) continue;
JSONObject req = params.optJSONObject("request");
if (req == null) continue;
String url = req.optString("url", "");
if (url.contains("/youtubei/v1/player")) {
String postData = req.optString("postData", null);
// In Chrome 130+, request payload may be in postDataEntries as Base64 bytes
if ((postData == null || postData.isEmpty()) && req.has("postDataEntries")) {
org.json.JSONArray entries = req.optJSONArray("postDataEntries");
if (entries != null) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < entries.length(); i++) {
JSONObject entryObj = entries.optJSONObject(i);
if (entryObj != null && entryObj.has("bytes")) {
try {
byte[] decoded = java.util.Base64.getDecoder().decode(entryObj.getString("bytes"));
sb.append(new String(decoded, StandardCharsets.UTF_8));
} catch (Exception ignored) {}
}
}
if (sb.length() > 0) {
postData = sb.toString();
}
}
}
if (postData == null || postData.isEmpty()) continue;
JSONObject body = new JSONObject(postData);
String visitorData = null;
if (body.has("context") && body.getJSONObject("context").has("client")) {
visitorData = body.getJSONObject("context").getJSONObject("client").optString("visitorData", null);
}
String poToken = null;
if (body.has("serviceIntegrityDimensions")) {
poToken = body.getJSONObject("serviceIntegrityDimensions").optString("poToken", null);
}
if (visitorData != null && poToken != null) {
LOGGER.info("Successfully retrieved PO token & visitor data for YouTube playback.");
LOGGER.debug("PO token: {}", poToken);
LOGGER.debug("Visitor Data: {}", visitorData);
// Set PO token and visitor data on both Web and WebEmbedded clients
Web.setPoTokenAndVisitorData(poToken, visitorData);
WebEmbedded.setPoTokenAndVisitorData(poToken, visitorData);
success = true;
break;
}
}
}
} catch (Exception e) {
LOGGER.debug("Error parsing performance log entry: {}", e.getMessage());
}
}
if (success) break;
// If still not captured after a while, try re-triggering play inside iframe
if (System.currentTimeMillis() > nextActionTime) {
try {
driver.switchTo().frame("player");
((JavascriptExecutor) driver).executeScript(
"var p = document.getElementById('movie_player') || document.querySelector('video'); " +
"if (p) { if (p.playVideo) p.playVideo(); else if (p.play) p.play(); }"
);
driver.switchTo().defaultContent();
} catch (Exception ignored) {}
nextActionTime = System.currentTimeMillis() + 5_000;
}
Thread.sleep(500);
}
if (!success) {
saveDebugPage(driver);
throw new Exception("Timed out waiting for YouTube player request. Current URL: " + driver.getCurrentUrl());
}
} catch (Exception e) {
LOGGER.error("Failed to obtain PO token for YouTube playback: {}", e.getMessage());
LOGGER.debug("Exception:", e);
} finally {
interceptor.close();
// Only quit the browser when not in headless mode. Helps with troubleshooting.
if (server != null) {
try {
server.stop(0);
} catch (Exception ignored) {}
}
// Only quit the browser when in headless mode. Helps with troubleshooting.
if (headless)
driver.quit();
}
}
private static void saveDebugPage(WebDriver driver) {
try {
StringBuilder sb = new StringBuilder();
String currentUrl = driver.getCurrentUrl();
sb.append("<!-- Current URL: ").append(currentUrl).append(" -->\n");
sb.append("<!-- MAIN PAGE SOURCE -->\n");
try {
sb.append(driver.getPageSource()).append("\n\n");
} catch (Exception ignored) {}
try {
driver.switchTo().frame("player");
sb.append("<!-- IFRAME SOURCE -->\n");
sb.append(driver.getPageSource()).append("\n");
driver.switchTo().defaultContent();
} catch (Exception ignored) {}
File debugFile = new File("youtube_debug.html");
try (FileWriter writer = new FileWriter(debugFile)) {
writer.write(sb.toString());
}
LOGGER.info("Saved YouTube debug page source to {} (Current URL: {})", debugFile.getAbsolutePath(), currentUrl);
} catch (Exception e) {
LOGGER.warn("Failed to save YouTube debug page source: {}", e.getMessage());
}
}
}