diff --git a/.gitignore b/.gitignore index 26c2ea5d..80ae0374 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ replay_pid* */build/* build .vscode/* +.cursor/* application.yml -playerconfigs/* \ No newline at end of file +playerconfigs/* +gradle.properties diff --git a/README.md b/README.md index 0562c9ec..57dcdc91 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Which clients are used is entirely configurable. - Information on using a `poToken` with `youtube-source`. - [Using a remote cipher server](#using-a-remote-cipher-server) - Information on using a remote cipher server with `youtube-source`. +- [Debugging / Saving raw responses](#debugging--saving-raw-responses) + - Information on saving HTTP request/response data for debugging. - [REST Routes (`plugin` only)](#rest-routes-plugin-only) - Information on the REST routes provided by the `youtube-source` plugin module. - [Migration Information](#migration-from-lavaplayers-built-in-youtube-source) @@ -329,6 +331,24 @@ plugins: userAgent: "your_service_name" # Optional user-agent header, used for metrics on the backend. ``` +## Debugging / Saving raw responses + +When troubleshooting playback issues (e.g. OAuth problems, stream decoding errors), it can be helpful to capture the HTTP request and response. + +### Lavaplayer +```java +YoutubeSourceOptions options = new YoutubeSourceOptions() + .setDebugSaveResponsesDirectory("/path/to/debug/output"); +YoutubeAudioSourceManager sourceManager = new YoutubeAudioSourceManager(options, ...); +``` + +### Lavalink +```yaml +plugins: + youtube: + debugSaveResponsesDirectory: "/path/to/debug/output" +``` + ## REST routes (`plugin` only) ### `POST` `/youtube` diff --git a/common/src/main/java/dev/lavalink/youtube/YoutubeAudioSourceManager.java b/common/src/main/java/dev/lavalink/youtube/YoutubeAudioSourceManager.java index 78080baf..f23ea2cd 100644 --- a/common/src/main/java/dev/lavalink/youtube/YoutubeAudioSourceManager.java +++ b/common/src/main/java/dev/lavalink/youtube/YoutubeAudioSourceManager.java @@ -3,6 +3,7 @@ import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager; import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpConfigurable; import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools; import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity; @@ -39,12 +40,17 @@ import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Collectors; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.impl.client.HttpClientBuilder; + import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS; @SuppressWarnings("RegExpUnnecessaryNonCapturingGroup") -public class YoutubeAudioSourceManager implements AudioSourceManager { +public class YoutubeAudioSourceManager implements AudioSourceManager, HttpConfigurable { // TODO: connect timeout = 16000ms, read timeout = 8000ms (as observed from scraped youtube config) // TODO: look at possibly scraping jsUrl from WEB config to save a request // TODO(music): scrape config? it's identical to WEB. @@ -160,6 +166,11 @@ public YoutubeAudioSourceManager(@NotNull YoutubeSourceOptions options, } else { this.cipherManager = new LocalSignatureCipherManager(); } + + if (!DataFormatTools.isNullOrEmpty(options.getDebugSaveResponsesDirectory())) { + contextFilter.setDebugSaveResponsesDirectory(options.getDebugSaveResponsesDirectory()); + log.info("Debug response saving enabled, writing to: {}", options.getDebugSaveResponsesDirectory()); + } } @Override @@ -454,6 +465,16 @@ public HttpInterface getInterface() { return httpInterfaceManager.getInterface(); } + @Override + public void configureRequests(Function configurator) { + httpInterfaceManager.configureRequests(configurator); + } + + @Override + public void configureBuilder(Consumer configurator) { + httpInterfaceManager.configureBuilder(configurator); + } + @Override public boolean isTrackEncodable(AudioTrack track) { return true; diff --git a/common/src/main/java/dev/lavalink/youtube/YoutubeSourceOptions.java b/common/src/main/java/dev/lavalink/youtube/YoutubeSourceOptions.java index fd40b161..36bebd48 100644 --- a/common/src/main/java/dev/lavalink/youtube/YoutubeSourceOptions.java +++ b/common/src/main/java/dev/lavalink/youtube/YoutubeSourceOptions.java @@ -9,6 +9,7 @@ public class YoutubeSourceOptions { private String remoteCipherUrl; private String remoteCipherPassword; private String remoteCipherUserAgent; + private String debugSaveResponsesDirectory; public boolean isAllowSearch() { return allowSearch; @@ -57,5 +58,13 @@ public String getRemoteCipherUserAgent() { return remoteCipherUserAgent; } + @Nullable + public String getDebugSaveResponsesDirectory() { + return debugSaveResponsesDirectory; + } + public YoutubeSourceOptions setDebugSaveResponsesDirectory(@Nullable String debugSaveResponsesDirectory) { + this.debugSaveResponsesDirectory = debugSaveResponsesDirectory; + return this; + } } diff --git a/common/src/main/java/dev/lavalink/youtube/cipher/CipherManager.java b/common/src/main/java/dev/lavalink/youtube/cipher/CipherManager.java index 487e5cc7..2a377671 100644 --- a/common/src/main/java/dev/lavalink/youtube/cipher/CipherManager.java +++ b/common/src/main/java/dev/lavalink/youtube/cipher/CipherManager.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; /** @@ -41,7 +42,7 @@ default CachedPlayerScript getPlayerScript(@NotNull HttpInterface httpInterface) try (CloseableHttpResponse response = httpInterface.execute(new HttpGet("https://www.youtube.com/embed/"))) { HttpClientTools.assertSuccessWithContent(response, "fetch player script (embed)"); - String responseText = EntityUtils.toString(response.getEntity()); + String responseText = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); String scriptUrl = DataFormatTools.extractBetween(responseText, "\"jsUrl\":\"", "\""); if (scriptUrl == null) { diff --git a/common/src/main/java/dev/lavalink/youtube/clients/Web.java b/common/src/main/java/dev/lavalink/youtube/clients/Web.java index 9dcb8db8..b89c0bb7 100644 --- a/common/src/main/java/dev/lavalink/youtube/clients/Web.java +++ b/common/src/main/java/dev/lavalink/youtube/clients/Web.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.net.URISyntaxException; import java.util.List; import java.util.Map; @@ -70,7 +71,7 @@ protected void fetchClientConfig(@NotNull HttpInterface httpInterface) { HttpClientTools.assertSuccessWithContent(response, "client config fetch"); lastConfigUpdate = System.currentTimeMillis(); - String page = EntityUtils.toString(response.getEntity()); + String page = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); Matcher m = CONFIG_REGEX.matcher(page); if (!m.find()) { diff --git a/common/src/main/java/dev/lavalink/youtube/clients/skeleton/Client.java b/common/src/main/java/dev/lavalink/youtube/clients/skeleton/Client.java index 9ee87eae..3bcff130 100644 --- a/common/src/main/java/dev/lavalink/youtube/clients/skeleton/Client.java +++ b/common/src/main/java/dev/lavalink/youtube/clients/skeleton/Client.java @@ -46,24 +46,31 @@ public interface Client { default PlayabilityStatus getPlayabilityStatus(@NotNull JsonBrowser playabilityStatus, boolean throwOnNotOk) throws CannotBeLoaded { String status = playabilityStatus.get("status").text(); + String reason = playabilityStatus.get("reason").safeText(); if (playabilityStatus.isNull() || status == null) { throw new RuntimeException("No playability status block."); } + org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Client.class); + log.debug("Playability status check: status={}, reason={}, throwOnNotOk={}", status, reason, throwOnNotOk); + switch (status) { case "OK": + log.debug("Playability status OK"); return PlayabilityStatus.OK; case "ERROR": - String reason = playabilityStatus.get("reason").text(); + String errorReason = playabilityStatus.get("reason").text(); + log.debug("Playability status ERROR: {}", errorReason); -// if (reason.contains("This video is unavailable")) { -// throw new CannotBeLoaded(new FriendlyException(reason, COMMON, null)); +// if (errorReason.contains("This video is unavailable")) { +// throw new CannotBeLoaded(new FriendlyException(errorReason, COMMON, null)); // } - throw new FriendlyException(reason, COMMON, null); + throw new FriendlyException(errorReason, COMMON, null); case "UNPLAYABLE": String unplayableReason = getUnplayableReason(playabilityStatus); + log.debug("Playability status UNPLAYABLE: {}", unplayableReason); if (unplayableReason == null) { // We should have a reason so this is suspicious. @@ -71,12 +78,14 @@ default PlayabilityStatus getPlayabilityStatus(@NotNull JsonBrowser playabilityS } if (unplayableReason.contains("Playback on other websites has been disabled by the video owner") && !throwOnNotOk) { + log.debug("Video is non-embeddable, returning NON_EMBEDDABLE status"); return PlayabilityStatus.NON_EMBEDDABLE; } throw new FriendlyException(unplayableReason, COMMON, null); case "LOGIN_REQUIRED": String loginReason = playabilityStatus.get("reason").safeText(); + log.debug("Playability status LOGIN_REQUIRED: {}", loginReason); if (loginReason.contains("This video is private")) { throw new CannotBeLoaded(new FriendlyException("This is a private video.", COMMON, null)); diff --git a/common/src/main/java/dev/lavalink/youtube/clients/skeleton/NonMusicClient.java b/common/src/main/java/dev/lavalink/youtube/clients/skeleton/NonMusicClient.java index e960d64d..65697e48 100644 --- a/common/src/main/java/dev/lavalink/youtube/clients/skeleton/NonMusicClient.java +++ b/common/src/main/java/dev/lavalink/youtube/clients/skeleton/NonMusicClient.java @@ -12,13 +12,10 @@ import dev.lavalink.youtube.cipher.CipherManager.CachedPlayerScript; import dev.lavalink.youtube.clients.ClientConfig; import dev.lavalink.youtube.track.TemporalInfo; -import org.apache.http.HttpResponse; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -74,7 +71,7 @@ protected JsonBrowser loadJsonResponse(@NotNull HttpInterface httpInterface, // from my testing, json is always returned so might not be necessary. HttpClientTools.assertJsonContentType(response); - String json = EntityUtils.toString(response.getEntity()); + String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); log.trace("Response from {} ({}) {}", request.getURI(), context, json); return JsonBrowser.parse(json); @@ -133,7 +130,7 @@ protected JsonBrowser loadTrackInfoFromInnertube(@NotNull YoutubeAudioSourceMana // For embedded clients, fetch and include encryptedHostFlags to avoid playback restrictions. if (isEmbedded()) { - String encryptedHostFlags = fetchEncryptedHostFlags(videoId); + String encryptedHostFlags = fetchEncryptedHostFlags(httpInterface, videoId); if (encryptedHostFlags != null) { config.withEncryptedHostFlags(encryptedHostFlags); } @@ -156,10 +153,19 @@ protected JsonBrowser loadTrackInfoFromInnertube(@NotNull YoutubeAudioSourceMana JsonBrowser playabilityJson = json.get("playabilityStatus"); JsonBrowser videoDetails = json.get("videoDetails"); + log.debug("Client {} received player API response for video {}: playabilityStatus={}, videoDetails present={}", + getIdentifier(), + videoId, + playabilityJson.isNull() ? "null" : playabilityJson.get("status").text(), + !videoDetails.isNull()); + // we should always check playabilityStatus if videoDetails is null because it could contain important // information as to why, which prevents false reports about this not working as intended etc etc. if (validatePlayabilityStatus || videoDetails.isNull()) { // fix: Make this method throw if a status was supplied (typically when we recurse). + String playabilityStatusStr = playabilityJson.isNull() ? "null" : playabilityJson.get("status").text(); + log.debug("Validating playability status for video {} with client {}: status={}, previousStatus={}", + videoId, getIdentifier(), playabilityStatusStr, status); PlayabilityStatus playabilityStatus = getPlayabilityStatus(playabilityJson, status != null); // All other branches should've been caught by getPlayabilityStatus(). @@ -178,17 +184,24 @@ protected JsonBrowser loadTrackInfoFromInnertube(@NotNull YoutubeAudioSourceMana } if (videoDetails.isNull()) { + log.warn("Client {} received null videoDetails for video {}. Playability status: {}, Full JSON: {}", + getIdentifier(), videoId, playabilityJson.format(), json.format()); throw new FriendlyException("Loading information for video failed", Severity.SUSPICIOUS, new RuntimeException("Missing videoDetails block, JSON: " + json.format())); } - if (!videoId.equals(videoDetails.get("videoId").text())) { + String returnedVideoId = videoDetails.get("videoId").text(); + if (!videoId.equals(returnedVideoId)) { + log.warn("Client {} returned incorrect video ID. Expected: {}, Got: {}, Full JSON: {}", + getIdentifier(), videoId, returnedVideoId, json.format()); throw new FriendlyException( "The video returned is not what was requested.", Severity.SUSPICIOUS, new RuntimeException("Incorrect video response, JSON: " + json.format()) ); } + + log.debug("Client {} successfully loaded video details for video {}", getIdentifier(), videoId); return json; } @@ -196,26 +209,29 @@ protected JsonBrowser loadTrackInfoFromInnertube(@NotNull YoutubeAudioSourceMana /** * Fetches the encryptedHostFlags from the YouTube embed page. * This is required for embedded clients to avoid playback restrictions. + * Uses the given HttpInterface so proxy and other HTTP config (e.g. from the player manager) are applied. * + * @param httpInterface The HTTP interface to use (respects proxy and builder configurator). * @param videoId The video ID to fetch the embed page for. * @return The encryptedHostFlags value, or null if not found. */ @Nullable - protected String fetchEncryptedHostFlags(@NotNull String videoId) { + protected String fetchEncryptedHostFlags(@NotNull HttpInterface httpInterface, @NotNull String videoId) { String embedUrl = "https://www.youtube.com/embed/" + videoId; - try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + try { HttpGet request = new HttpGet(embedUrl); request.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - HttpResponse response = httpClient.execute(request); - String html = EntityUtils.toString(response.getEntity()); + try (CloseableHttpResponse response = httpInterface.execute(request)) { + String html = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - Pattern pattern = Pattern.compile("\"encryptedHostFlags\":\"([^\"]+)\""); - Matcher matcher = pattern.matcher(html); + Pattern pattern = Pattern.compile("\"encryptedHostFlags\":\"([^\"]+)\""); + Matcher matcher = pattern.matcher(html); - if (matcher.find()) { - return matcher.group(1); + if (matcher.find()) { + return matcher.group(1); + } } } catch (IOException e) { log.debug("Failed to fetch encryptedHostFlags for video {}", videoId, e); diff --git a/common/src/main/java/dev/lavalink/youtube/clients/skeleton/StreamingNonMusicClient.java b/common/src/main/java/dev/lavalink/youtube/clients/skeleton/StreamingNonMusicClient.java index 0381f4c1..52b52bfa 100644 --- a/common/src/main/java/dev/lavalink/youtube/clients/skeleton/StreamingNonMusicClient.java +++ b/common/src/main/java/dev/lavalink/youtube/clients/skeleton/StreamingNonMusicClient.java @@ -32,22 +32,35 @@ public abstract class StreamingNonMusicClient extends NonMusicClient { public TrackFormats loadFormats(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String videoId) throws CannotBeLoaded, IOException { + log.debug("Client {} loading formats for video {}", getIdentifier(), videoId); JsonBrowser json = loadTrackInfoFromInnertube(source, httpInterface, videoId, null, true); JsonBrowser playabilityStatus = json.get("playabilityStatus"); JsonBrowser videoDetails = json.get("videoDetails"); CachedPlayerScript playerScript = source.getCipherManager().getCachedPlayerScript(httpInterface); boolean isLive = videoDetails.get("isLive").asBoolean(false); + log.debug("Client {} video {} isLive={}", getIdentifier(), videoId, isLive); if ("OK".equals(playabilityStatus.get("status").text()) && playabilityStatus.get("reason").safeText().contains("This live event has ended")) { // Long videos after ending of stream don't contain contentLength field as they // are still being processed by YouTube. + log.debug("Client {} detected ended live event for video {}", getIdentifier(), videoId); isLive = true; } JsonBrowser streamingData = json.get("streamingData"); + if (streamingData.isNull()) { + log.warn("Client {} received null streamingData for video {}, full JSON: {}", + getIdentifier(), videoId, json.format()); + } JsonBrowser mergedFormats = streamingData.get("formats"); JsonBrowser adaptiveFormats = streamingData.get("adaptiveFormats"); + + log.debug("Client {} found {} merged formats and {} adaptive formats for video {}", + getIdentifier(), + mergedFormats.isNull() ? 0 : mergedFormats.values().size(), + adaptiveFormats.isNull() ? 0 : adaptiveFormats.values().size(), + videoId); List formats = new ArrayList<>(); boolean anyFailures = false; @@ -65,9 +78,12 @@ public TrackFormats loadFormats(@NotNull YoutubeAudioSourceManager source, } if (formats.isEmpty() && anyFailures) { - log.warn("Loading formats either failed to load or were skipped due to missing fields, json: {}", streamingData.format()); + log.warn("Client {} loading formats either failed to load or were skipped due to missing fields for video {}, json: {}", + getIdentifier(), videoId, streamingData.format()); } + log.debug("Client {} successfully extracted {} formats for video {}", + getIdentifier(), formats.size(), videoId); return new TrackFormats(formats, playerScript.url); } @@ -86,7 +102,9 @@ protected boolean extractFormat(JsonBrowser formatJson, : Collections.emptyMap(); if (DataFormatTools.isNullOrEmpty(url) && DataFormatTools.isNullOrEmpty(cipherInfo.get("url"))) { - log.debug("Client '{}' is missing format URL for itag '{}'. SABR response?", getIdentifier(), formatJson.get("itag").text()); + String itag = formatJson.get("itag").text(); + log.debug("Client '{}' is missing format URL for itag '{}'. SABR response? Format JSON: {}", + getIdentifier(), itag, formatJson.format()); return false; } diff --git a/common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java b/common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java index 3600a497..af3ad573 100644 --- a/common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java +++ b/common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java @@ -4,11 +4,25 @@ import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; import dev.lavalink.youtube.clients.skeleton.Client; +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpEntityEnclosingRequest; import org.apache.http.HttpResponse; import org.apache.http.client.CookieStore; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.entity.ByteArrayEntity; import org.apache.http.impl.client.BasicCookieStore; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -24,7 +38,24 @@ public class YoutubeHttpContextFilter extends BaseYoutubeHttpContextFilter { public static final String ATTRIBUTE_VISITOR_DATA_SPECIFIED = "clientVisitorData"; public static final String ATTRIBUTE_CIPHER_REQUEST_SPECIFIED = "remoteCipherRequest"; + // Context attributes for debug saving + private static final String ATTRIBUTE_DEBUG_REQUEST_INFO_REDACTED = "debugRequestInfoRedacted"; + private static final String ATTRIBUTE_DEBUG_REQUEST_INFO_RAW = "debugRequestInfoRaw"; + + // Sensitive query params to redact + private static final Pattern SENSITIVE_QUERY_PARAMS = Pattern.compile( + "(access_token|token|oauth_token|refresh_token|key|sig|signature)=([^&]*)", + Pattern.CASE_INSENSITIVE + ); + + // Sensitive JSON fields to redact + private static final Pattern SENSITIVE_JSON_FIELDS = Pattern.compile( + "(\"(?:access_token|oauth_token|token|refresh_token|authorization|sig|signature)\"\\s*:\\s*)\"[^\"]*\"", + Pattern.CASE_INSENSITIVE + ); + private static final HttpContextRetryCounter retryCounter = new HttpContextRetryCounter("yt-token-retry"); + private static final AtomicLong debugFileCounter = new AtomicLong(0); private YoutubeAccessTokenTracker tokenTracker; private YoutubeOauth2Handler oauth2Handler; @@ -32,6 +63,7 @@ public class YoutubeHttpContextFilter extends BaseYoutubeHttpContextFilter { private String remoteCipherPass; private String remoteCipherUserAgent; private String pluginVersion; + private String debugSaveResponsesDirectory; public void setTokenTracker(@NotNull YoutubeAccessTokenTracker tokenTracker) { this.tokenTracker = tokenTracker; @@ -49,6 +81,9 @@ public void setCipherConfig(@Nullable String remotePass, this.pluginVersion = pluginVersion; } + public void setDebugSaveResponsesDirectory(@Nullable String directory) { + this.debugSaveResponsesDirectory = directory; + } @Override public void onContextOpen(HttpClientContext context) { @@ -117,6 +152,34 @@ public void onRequest(HttpClientContext context, oauth2Handler.applyToken(request); } } + } else { + // googlevideo.com: when formats were obtained with OAuth, the stream/segment requests + // must also send the same auth or YouTube may return 403/error page instead of video, + // causing decoding failures (e.g. "Expected decoding to halt, got: 5"). + String oauthToken = context.getAttribute(OAUTH_INJECT_CONTEXT_ATTRIBUTE, String.class); + if (oauthToken != null && !oauthToken.isEmpty()) { + oauth2Handler.applyToken(request, oauthToken); + } else if (oauth2Handler.hasAccessToken()) { + oauth2Handler.applyToken(request); + } + if (log.isDebugEnabled()) { + boolean authPresent = request.getFirstHeader("Authorization") != null; + log.debug("Stream request to googlevideo: uri={}, Authorization header present={}", request.getURI(), authPresent); + } + } + + // Capture request info for debug saving (after all headers are set) + if (!DataFormatTools.isNullOrEmpty(debugSaveResponsesDirectory)) { + String host = request.getURI().getHost(); + if (host != null && (host.contains("youtubei.googleapis.com") || host.contains("googlevideo"))) { + try { + String[] requestInfo = captureRequestInfo(request); + context.setAttribute(ATTRIBUTE_DEBUG_REQUEST_INFO_REDACTED, requestInfo[0]); + context.setAttribute(ATTRIBUTE_DEBUG_REQUEST_INFO_RAW, requestInfo[1]); + } catch (Exception e) { + log.warn("Failed to capture request info for debug saving", e); + } + } } // try { @@ -134,17 +197,223 @@ public void onRequest(HttpClientContext context, // } } + private static final int DEBUG_RESPONSE_HEAD_BYTES = 64; + @Override public boolean onRequestResponse(HttpClientContext context, HttpUriRequest request, HttpResponse response) { + String host = request.getURI().getHost(); + int status = response.getStatusLine().getStatusCode(); + + // Debug logging for googlevideo + if (log.isDebugEnabled() && host != null && host.contains("googlevideo")) { + String contentType = response.getFirstHeader("Content-Type") != null + ? response.getFirstHeader("Content-Type").getValue() + : "(none)"; + log.debug("Stream response from googlevideo: uri={}, status={}, Content-Type={}", request.getURI(), status, contentType); + if (status != 200) { + HttpEntity entity = response.getEntity(); + if (entity != null) { + try { + byte[] body = EntityUtils.toByteArray(entity); + response.setEntity(new ByteArrayEntity(body)); + int head = Math.min(DEBUG_RESPONSE_HEAD_BYTES, body.length); + StringBuilder hex = new StringBuilder(head * 2); + for (int i = 0; i < head; i++) { + hex.append(String.format(Locale.ROOT, "%02x", body[i] & 0xff)); + } + log.debug("Stream response first {} bytes (hex, status != 200): {}", head, hex); + } catch (Exception e) { + log.debug("Could not read stream response body for logging", e); + } + } + } + } + + // Debug save to files + if (!DataFormatTools.isNullOrEmpty(debugSaveResponsesDirectory) && + host != null && (host.contains("youtubei.googleapis.com") || host.contains("googlevideo"))) { + try { + saveDebugFiles(context, request, response, host, status); + } catch (Exception e) { + log.warn("Failed to save debug files for request to {}", host, e); + } + } -// if (tokenTracker.isTokenFetchContext(context) || retryCounter.getRetryCount(context) >= 1) { -// return false; -// } return false; } + /** + * Captures request info for debug saving, building both redacted and raw versions in a single pass. + * @return String array: [0] = redacted, [1] = raw + */ + private String[] captureRequestInfo(HttpUriRequest request) throws IOException { + StringBuilder redacted = new StringBuilder(); + StringBuilder raw = new StringBuilder(); + + // Method and URI + String method = request.getMethod(); + String uriRaw = request.getURI().toString(); + String uriRedacted = redactUri(request.getURI()); + + redacted.append(method).append(" ").append(uriRedacted).append("\n"); + raw.append(method).append(" ").append(uriRaw).append("\n"); + + // Headers + for (Header header : request.getAllHeaders()) { + String name = header.getName(); + String value = header.getValue(); + + raw.append(name).append(": ").append(value).append("\n"); + + if ("Authorization".equalsIgnoreCase(name)) { + redacted.append(name).append(": ***REDACTED***\n"); + } else { + redacted.append(name).append(": ").append(value).append("\n"); + } + } + + redacted.append("\n"); + raw.append("\n"); + + // Request body (if repeatable) + if (request instanceof HttpEntityEnclosingRequest) { + HttpEntityEnclosingRequest entityRequest = (HttpEntityEnclosingRequest) request; + HttpEntity entity = entityRequest.getEntity(); + if (entity != null && entity.isRepeatable()) { + try { + byte[] bodyBytes = EntityUtils.toByteArray(entity); + String bodyStr = new String(bodyBytes, StandardCharsets.UTF_8); + + raw.append(bodyStr); + redacted.append(redactJsonFields(bodyStr)); + } catch (Exception e) { + String err = "[Request body could not be read: " + e.getMessage() + "]"; + redacted.append(err); + raw.append(err); + } + } else if (entity != null) { + String msg = "[Request body not repeatable, skipped]"; + redacted.append(msg); + raw.append(msg); + } + } + + return new String[] { redacted.toString(), raw.toString() }; + } + + private String redactUri(URI uri) { + String uriStr = uri.toString(); + return SENSITIVE_QUERY_PARAMS.matcher(uriStr).replaceAll("$1=***REDACTED***"); + } + + private String redactJsonFields(String json) { + return SENSITIVE_JSON_FIELDS.matcher(json).replaceAll("$1\"***REDACTED***\""); + } + + /** + * Formats headers, building both redacted and raw versions in a single pass. + * @return String array: [0] = redacted, [1] = raw + */ + private String[] formatHeaders(Header[] headers) { + StringBuilder redacted = new StringBuilder(); + StringBuilder raw = new StringBuilder(); + + for (Header header : headers) { + String name = header.getName(); + String value = header.getValue(); + + raw.append(name).append(": ").append(value).append("\n"); + + if ("Authorization".equalsIgnoreCase(name)) { + redacted.append(name).append(": ***REDACTED***\n"); + } else { + redacted.append(name).append(": ").append(value).append("\n"); + } + } + + return new String[] { redacted.toString(), raw.toString() }; + } + + private void saveDebugFiles(HttpClientContext context, + HttpUriRequest request, + HttpResponse response, + String host, + int status) throws IOException { + // Generate unique file prefix + String hostPrefix = host.contains("googlevideo") ? "googlevideo" : "youtubei"; + long timestamp = System.currentTimeMillis(); + long counter = debugFileCounter.incrementAndGet(); + String prefix = String.format(Locale.ROOT, "%s_%d_%d_%d", hostPrefix, timestamp, status, counter); + + Path baseDir = Path.of(debugSaveResponsesDirectory); + Path redactedDir = baseDir.resolve("redacted"); + Path rawDir = baseDir.resolve("raw"); + Files.createDirectories(redactedDir); + Files.createDirectories(rawDir); + + // Write request files (both versions) + String requestRedacted = context.getAttribute(ATTRIBUTE_DEBUG_REQUEST_INFO_REDACTED, String.class); + String requestRaw = context.getAttribute(ATTRIBUTE_DEBUG_REQUEST_INFO_RAW, String.class); + + if (requestRedacted != null) { + Files.writeString(redactedDir.resolve(prefix + "_request.txt"), requestRedacted, StandardCharsets.UTF_8); + } + if (requestRaw != null) { + Files.writeString(rawDir.resolve(prefix + "_request.txt"), requestRaw, StandardCharsets.UTF_8); + } + + // Read response body once + HttpEntity entity = response.getEntity(); + byte[] responseBody = null; + if (entity != null) { + responseBody = EntityUtils.toByteArray(entity); + response.setEntity(new ByteArrayEntity(responseBody)); + } + + // Format headers once (both versions) + String[] headers = formatHeaders(response.getAllHeaders()); + String headersRedacted = headers[0]; + String headersRaw = headers[1]; + String statusLine = response.getStatusLine().toString(); + + // Determine content type + String contentType = response.getFirstHeader("Content-Type") != null + ? response.getFirstHeader("Content-Type").getValue() + : ""; + boolean isTextResponse = contentType.contains("json") || contentType.contains("text") || contentType.contains("html"); + boolean isGooglevideo = host.contains("googlevideo"); + + if (isGooglevideo && !isTextResponse) { + // Binary response: write meta file + body file separately + String metaRedacted = statusLine + "\n" + headersRedacted; + String metaRaw = statusLine + "\n" + headersRaw; + + Files.writeString(redactedDir.resolve(prefix + "_response_meta.txt"), metaRedacted, StandardCharsets.UTF_8); + Files.writeString(rawDir.resolve(prefix + "_response_meta.txt"), metaRaw, StandardCharsets.UTF_8); + + if (responseBody != null) { + String ext = contentType.contains("html") ? ".html" : ".bin"; + // Binary body is the same for both (no redaction needed for binary) + Files.write(redactedDir.resolve(prefix + "_response_body" + ext), responseBody); + Files.write(rawDir.resolve(prefix + "_response_body" + ext), responseBody); + } + } else { + // Text response: write single response file with status, headers, and body + String bodyRaw = responseBody != null ? new String(responseBody, StandardCharsets.UTF_8) : ""; + String bodyRedacted = redactJsonFields(bodyRaw); + + String ext = contentType.contains("html") ? ".html" : ".txt"; + + String responseRedacted = statusLine + "\n" + headersRedacted + "\n" + bodyRedacted; + String responseRaw = statusLine + "\n" + headersRaw + "\n" + bodyRaw; + + Files.writeString(redactedDir.resolve(prefix + "_response" + ext), responseRedacted, StandardCharsets.UTF_8); + Files.writeString(rawDir.resolve(prefix + "_response" + ext), responseRaw, StandardCharsets.UTF_8); + } + } + @Override public boolean onRequestException(HttpClientContext context, HttpUriRequest request, diff --git a/common/src/main/java/dev/lavalink/youtube/http/YoutubeOauth2Handler.java b/common/src/main/java/dev/lavalink/youtube/http/YoutubeOauth2Handler.java index 0f564804..88910877 100644 --- a/common/src/main/java/dev/lavalink/youtube/http/YoutubeOauth2Handler.java +++ b/common/src/main/java/dev/lavalink/youtube/http/YoutubeOauth2Handler.java @@ -333,7 +333,7 @@ public void applyToken(HttpUriRequest request) { // check again to ensure updating worked as expected. if (accessToken != null && tokenType != null && System.currentTimeMillis() < tokenExpires) { - log.debug("Using oauth authorization header with value \"{} {}\"", tokenType, accessToken); + log.debug("Applying OAuth authorization header ({} token, length={})", tokenType, accessToken != null ? accessToken.length() : 0); request.setHeader("Authorization", String.format("%s %s", tokenType, accessToken)); } } diff --git a/common/src/main/java/dev/lavalink/youtube/track/YoutubeAudioTrack.java b/common/src/main/java/dev/lavalink/youtube/track/YoutubeAudioTrack.java index 52cff224..fdce2bec 100644 --- a/common/src/main/java/dev/lavalink/youtube/track/YoutubeAudioTrack.java +++ b/common/src/main/java/dev/lavalink/youtube/track/YoutubeAudioTrack.java @@ -74,36 +74,70 @@ public void process(LocalAudioTrackExecutor localExecutor) throws Exception { try (HttpInterface httpInterface = sourceManager.getInterface()) { try { Object userData = getUserData(); + log.debug("Processing track {}, userData type: {}, userData value: {}", + getIdentifier(), + userData != null ? userData.getClass().getName() : "null", + userData != null ? userData.toString() : "null"); if (userData != null) { - JsonBrowser jsonUserData = JsonBrowser.parse(userData.toString()); + String userDataString = userData.toString(); + log.debug("Attempting to parse userData as JSON: {}", userDataString); + + JsonBrowser jsonUserData = JsonBrowser.parse(userDataString); if (jsonUserData.get("oauth-token") != null) { - httpInterface.getContext().setAttribute(OAUTH_INJECT_CONTEXT_ATTRIBUTE, jsonUserData.get("oauth-token").text()); + String oauthToken = jsonUserData.get("oauth-token").text(); + log.debug("Found oauth-token in userData, setting context attribute"); + httpInterface.getContext().setAttribute(OAUTH_INJECT_CONTEXT_ATTRIBUTE, oauthToken); + } else { + log.debug("No oauth-token found in userData JSON"); } } } catch (IOException e) { log.debug("Failed to parse token from userData", e); + } catch (Exception e) { + log.debug("Unexpected error while processing userData: {}", e.getMessage(), e); } List exceptions = new ArrayList<>(); + log.debug("Starting client iteration for track {}. Available clients: {}", + getIdentifier(), + Arrays.stream(clients) + .filter(Client::supportsFormatLoading) + .map(Client::getIdentifier) + .toArray(String[]::new)); for (Client client : clients) { if (!client.supportsFormatLoading()) { + log.debug("Skipping client {} as it does not support format loading", client.getIdentifier()); continue; } + log.debug("Attempting to load track {} with client {}", getIdentifier(), client.getIdentifier()); httpInterface.getContext().setAttribute(Client.OAUTH_CLIENT_ATTRIBUTE, client.supportsOAuth()); + log.debug("Client {} OAuth support: {}", client.getIdentifier(), client.supportsOAuth()); try { processWithClient(localExecutor, httpInterface, client, 0); + log.debug("Successfully loaded track {} with client {}", getIdentifier(), client.getIdentifier()); return; } catch (CannotBeLoaded e) { + log.debug("Client {} cannot load track {}: {}", client.getIdentifier(), getIdentifier(), e.getMessage(), e); throw e; } catch (Exception e) { + log.debug("Client {} failed to load track {}: {} (exception type: {})", + client.getIdentifier(), + getIdentifier(), + e.getMessage(), + e.getClass().getName(), + e); + if (e instanceof ScriptExtractionException) { // If we're still early in playback, we can try another client - if (localExecutor.getPosition() >= BAD_STREAM_POSITION_THRESHOLD_MS) { + long position = localExecutor.getPosition(); + log.debug("ScriptExtractionException at position {}ms (threshold: {}ms)", + position, BAD_STREAM_POSITION_THRESHOLD_MS); + if (position >= BAD_STREAM_POSITION_THRESHOLD_MS) { throw e; } } else if ("Not success status code: 403".equals(e.getMessage()) || @@ -111,7 +145,10 @@ public void process(LocalAudioTrackExecutor localExecutor) throws Exception { // As long as the executor position has not surpassed the threshold for which // a stream is considered unrecoverable, we can try to renew the playback URL with // another client. - if (localExecutor.getPosition() >= BAD_STREAM_POSITION_THRESHOLD_MS) { + long position = localExecutor.getPosition(); + log.debug("HTTP error {} at position {}ms (threshold: {}ms)", + e.getMessage(), position, BAD_STREAM_POSITION_THRESHOLD_MS); + if (position >= BAD_STREAM_POSITION_THRESHOLD_MS) { throw e; } } @@ -120,6 +157,11 @@ public void process(LocalAudioTrackExecutor localExecutor) throws Exception { } if (!exceptions.isEmpty()) { + log.warn("All clients failed to load track {}. Failed clients: {}", + getIdentifier(), + exceptions.stream() + .map(e -> String.format("%s (%s)", e.getClient().getIdentifier(), e.getMessage())) + .toArray(String[]::new)); throw new AllClientsFailedException(exceptions); } } catch (CannotBeLoaded e) { @@ -131,16 +173,28 @@ private void processWithClient(LocalAudioTrackExecutor localExecutor, HttpInterface httpInterface, Client client, long streamPosition) throws CannotBeLoaded, Exception { + log.debug("Loading best format for track {} with client {}", getIdentifier(), client.getIdentifier()); FormatWithUrl augmentedFormat = loadBestFormatWithUrl(httpInterface, client); - log.debug("Starting track with URL from client {}: {}", client.getIdentifier(), augmentedFormat.signedUrl); + log.debug("Starting track {} with URL from client {}: {} (format: {}, contentLength: {})", + getIdentifier(), + client.getIdentifier(), + augmentedFormat.signedUrl, + augmentedFormat.format.getType(), + augmentedFormat.format.getContentLength()); try { if (trackInfo.isStream || augmentedFormat.format.getContentLength() == CONTENT_LENGTH_UNKNOWN) { + log.debug("Processing track {} as stream (isStream: {}, contentLength: {})", + getIdentifier(), trackInfo.isStream, augmentedFormat.format.getContentLength()); processStream(localExecutor, httpInterface, augmentedFormat); } else { + log.debug("Processing track {} as static file (contentLength: {}, streamPosition: {})", + getIdentifier(), augmentedFormat.format.getContentLength(), streamPosition); processStatic(localExecutor, httpInterface, augmentedFormat, streamPosition); } } catch (StreamExpiredException e) { + log.debug("Stream expired for track {} at position {}ms, retrying with same client", + getIdentifier(), e.lastStreamPosition); processWithClient(localExecutor, httpInterface, client, e.lastStreamPosition); } } @@ -152,20 +206,30 @@ private void processStatic(LocalAudioTrackExecutor localExecutor, YoutubePersistentHttpStream stream = null; try { + log.debug("Creating persistent HTTP stream for track {} with URL: {}, contentLength: {}", + getIdentifier(), augmentedFormat.signedUrl, augmentedFormat.format.getContentLength()); stream = new YoutubePersistentHttpStream(httpInterface, augmentedFormat.signedUrl, augmentedFormat.format.getContentLength()); if (streamPosition > 0) { + log.debug("Seeking to position {}ms for track {}", streamPosition, getIdentifier()); stream.seek(streamPosition); } - if (augmentedFormat.format.getType().getMimeType().endsWith("/webm")) { + String mimeType = augmentedFormat.format.getType().getMimeType(); + log.debug("Processing static track {} with mimeType: {}", getIdentifier(), mimeType); + if (mimeType.endsWith("/webm")) { + log.debug("Delegating to MatroskaAudioTrack for track {}", getIdentifier()); processDelegate(new MatroskaAudioTrack(trackInfo, stream), localExecutor); } else { + log.debug("Delegating to MpegAudioTrack for track {}", getIdentifier()); processDelegate(new MpegAudioTrack(trackInfo, stream), localExecutor); } } catch (RuntimeException e) { + log.debug("RuntimeException while processing static track {}: {}", getIdentifier(), e.getMessage(), e); if ("Not success status code: 403".equals(e.getMessage()) && augmentedFormat.isExpired() && stream != null) { - throw new StreamExpiredException(stream.getPosition(), e); + long position = stream.getPosition(); + log.debug("Stream expired for track {} at position {}, throwing StreamExpiredException", getIdentifier(), position); + throw new StreamExpiredException(position, e); } throw e; @@ -180,9 +244,12 @@ private void processStream(LocalAudioTrackExecutor localExecutor, HttpInterface httpInterface, FormatWithUrl augmentedFormat) throws Exception { if (MIME_AUDIO_WEBM.equals(augmentedFormat.format.getType().getMimeType())) { + log.warn("Track {} requested WebM stream format which is not supported", getIdentifier()); throw new FriendlyException("YouTube WebM streams are currently not supported.", Severity.COMMON, null); } + log.debug("Delegating stream processing for track {} to YoutubeMpegStreamAudioTrack with URL: {}", + getIdentifier(), augmentedFormat.signedUrl); // TODO: Catch 403 and retry? Can't use position though because it's a livestream. processDelegate(new YoutubeMpegStreamAudioTrack(trackInfo, httpInterface, augmentedFormat.signedUrl), localExecutor); } @@ -194,19 +261,37 @@ private FormatWithUrl loadBestFormatWithUrl(@NotNull HttpInterface httpInterface throw new RuntimeException(client.getIdentifier() + " does not support loading of formats!"); } + log.debug("Requesting formats for track {} from client {}", getIdentifier(), client.getIdentifier()); TrackFormats formats = client.loadFormats(sourceManager, httpInterface, getIdentifier()); if (formats == null) { + log.debug("Client {} returned null formats for track {}", client.getIdentifier(), getIdentifier()); throw new FriendlyException("This video cannot be played", Severity.SUSPICIOUS, null); } + log.debug("Client {} returned {} formats for track {}, player script URL: {}", + client.getIdentifier(), + formats.getFormats().size(), + getIdentifier(), + formats.getPlayerScriptUrl()); + StreamFormat format = formats.getBestFormat(); + log.debug("Selected best format for track {}: itag={}, mimeType={}, bitrate={}, contentLength={}, url={}", + getIdentifier(), + format.getItag(), + format.getType().getMimeType(), + format.getBitrate(), + format.getContentLength(), + format.getUrl()); URI resolvedUrl = format.getUrl(); if (client.requirePlayerScript()) { + log.debug("Client {} requires player script, resolving format URL with cipher manager", client.getIdentifier()); resolvedUrl = sourceManager.getCipherManager() .resolveFormatUrl(httpInterface, formats.getPlayerScriptUrl(), format); + log.debug("Resolved format URL after cipher: {}", resolvedUrl); resolvedUrl = client.transformPlaybackUri(format.getUrl(), resolvedUrl); + log.debug("Transformed playback URI: {}", resolvedUrl); } return new FormatWithUrl(format, resolvedUrl); diff --git a/common/src/main/java/dev/lavalink/youtube/track/YoutubeMpegStreamAudioTrack.java b/common/src/main/java/dev/lavalink/youtube/track/YoutubeMpegStreamAudioTrack.java index 3379ed1d..50e9b48d 100644 --- a/common/src/main/java/dev/lavalink/youtube/track/YoutubeMpegStreamAudioTrack.java +++ b/common/src/main/java/dev/lavalink/youtube/track/YoutubeMpegStreamAudioTrack.java @@ -191,6 +191,9 @@ private boolean processNextSegment( } private void processSegmentStream(SeekableInputStream stream, AudioProcessingContext context, TrackState state) throws InterruptedException, IOException { + log.debug("Processing segment stream for track {}, absoluteSequence: {}, relativeSequence: {}, isStream: {}", + trackInfo.identifier, state.absoluteSequence, state.relativeSequence, trackInfo.isStream); + MpegFileLoader file = new MpegFileLoader(stream); file.parseHeaders(); @@ -201,18 +204,22 @@ private void processSegmentStream(SeekableInputStream stream, AudioProcessingCon if (sequenceInfo != null) { state.absoluteSequence = sequenceInfo.sequence; + log.debug("Extracted sequence info: sequence={}, duration={}", sequenceInfo.sequence, sequenceInfo.duration); } } if (state.trackConsumer == null) { + log.debug("Loading audio track consumer for track {}", trackInfo.identifier); state.trackConsumer = loadAudioTrack(file, context); } MpegFileTrackProvider fileReader = file.loadReader(state.trackConsumer); if (fileReader == null) { + log.warn("Failed to load file reader for track {}, format may be unsupported", trackInfo.identifier); throw new FriendlyException("Unknown MP4 format.", SUSPICIOUS, null); } + log.debug("Providing frames for track {}", trackInfo.identifier); fileReader.provideFrames(); } diff --git a/common/src/test/java/CipherManagerTest.java b/common/src/test/java/CipherManagerTest.java index e3979009..2190ebb9 100644 --- a/common/src/test/java/CipherManagerTest.java +++ b/common/src/test/java/CipherManagerTest.java @@ -16,6 +16,7 @@ import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; @@ -229,7 +230,7 @@ public void testScriptCaching() throws IOException { */ private String fetchCurrentPlayerScriptUrl(HttpInterface httpInterface) throws IOException { try (CloseableHttpResponse response = httpInterface.execute(new HttpGet("https://www.youtube.com/embed/"))) { - String responseText = EntityUtils.toString(response.getEntity()); + String responseText = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); // Extract the jsUrl from the response Pattern jsUrlPattern = Pattern.compile("\"jsUrl\":\"([^\"]+)\""); diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index e69de29b..00000000 diff --git a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java index 0522545f..80e8352e 100644 --- a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java +++ b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java @@ -19,6 +19,7 @@ public class YoutubeConfig { private String[] clients; private Map clientOptions = new HashMap<>(); private YoutubeOauthConfig oauth = null; + private String debugSaveResponsesDirectory = null; public boolean getEnabled() { return enabled; @@ -92,4 +93,11 @@ public void setRemoteCipher(YoutubeRemoteCipherConfig remoteCipher) { this.remoteCipher = remoteCipher; } + public String getDebugSaveResponsesDirectory() { + return debugSaveResponsesDirectory; + } + + public void setDebugSaveResponsesDirectory(String debugSaveResponsesDirectory) { + this.debugSaveResponsesDirectory = debugSaveResponsesDirectory; + } } diff --git a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java index 245db84e..eb1fcb87 100644 --- a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java +++ b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java @@ -184,6 +184,12 @@ public AudioPlayerManager configure(AudioPlayerManager audioPlayerManager) { log.info("Using remote cipher server with URL \"{}\"", cipherConfig.getUrl()); sourceOptions.setRemoteCipher(cipherConfig.getUrl(), cipherConfig.getPassword(), cipherConfig.getUserAgent()); } + + String debugSaveDir = youtubeConfig.getDebugSaveResponsesDirectory(); + if (debugSaveDir != null && !debugSaveDir.isEmpty()) { + log.info("Debug response saving enabled, directory: \"{}\"", debugSaveDir); + sourceOptions.setDebugSaveResponsesDirectory(debugSaveDir); + } } final YoutubeAudioSourceManager source = new YoutubeAudioSourceManager(sourceOptions, clients);