├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.md └── workflows │ ├── autocloser.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── common ├── build.gradle.kts └── src │ ├── main │ ├── java │ │ └── dev │ │ │ └── lavalink │ │ │ └── youtube │ │ │ ├── CannotBeLoaded.java │ │ │ ├── ClientInformation.java │ │ │ ├── OptionDisabledException.java │ │ │ ├── UrlTools.java │ │ │ ├── YoutubeAudioSourceManager.java │ │ │ ├── YoutubeSource.java │ │ │ ├── YoutubeSourceOptions.java │ │ │ ├── cipher │ │ │ ├── CipherOperation.java │ │ │ ├── CipherOperationType.java │ │ │ ├── ScriptExtractionException.java │ │ │ ├── SignatureCipher.java │ │ │ └── SignatureCipherManager.java │ │ │ ├── clients │ │ │ ├── Android.java │ │ │ ├── AndroidMusic.java │ │ │ ├── AndroidVr.java │ │ │ ├── ClientConfig.java │ │ │ ├── ClientOptions.java │ │ │ ├── ClientWithOptions.java │ │ │ ├── Ios.java │ │ │ ├── MWeb.java │ │ │ ├── Music.java │ │ │ ├── Tv.java │ │ │ ├── TvHtml5Embedded.java │ │ │ ├── Web.java │ │ │ ├── WebEmbedded.java │ │ │ └── skeleton │ │ │ │ ├── Client.java │ │ │ │ ├── MusicClient.java │ │ │ │ ├── NonMusicClient.java │ │ │ │ └── StreamingNonMusicClient.java │ │ │ ├── http │ │ │ ├── BaseYoutubeHttpContextFilter.java │ │ │ ├── YoutubeAccessTokenTracker.java │ │ │ ├── YoutubeHttpContextFilter.java │ │ │ └── YoutubeOauth2Handler.java │ │ │ ├── polyfill │ │ │ └── DetailMessageBuilder.java │ │ │ └── track │ │ │ ├── TemporalInfo.java │ │ │ ├── YoutubeAudioTrack.java │ │ │ ├── YoutubeMpegStreamAudioTrack.java │ │ │ ├── YoutubePersistentHttpStream.java │ │ │ └── format │ │ │ ├── FormatInfo.java │ │ │ ├── StreamFormat.java │ │ │ └── TrackFormats.java │ └── resources │ │ └── yts-version.txt │ └── test │ └── java │ └── SignatureCipherManagerTest.java ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml ├── plugin ├── build.gradle.kts └── src │ └── main │ ├── java │ └── dev │ │ └── lavalink │ │ └── youtube │ │ └── plugin │ │ ├── ClientProvider.java │ │ ├── ClientProviderV3.java │ │ ├── ClientProviderV4.java │ │ ├── IOUtils.java │ │ ├── PluginInfo.java │ │ ├── Pot.java │ │ ├── YoutubeConfig.java │ │ ├── YoutubeOauthConfig.java │ │ ├── YoutubePluginLoader.java │ │ ├── YoutubeRestHandler.java │ │ └── rest │ │ ├── MinimalConfigRequest.java │ │ └── MinimalConfigResponse.java │ └── resources │ └── yts-version.txt ├── settings.gradle.kts └── v2 ├── build.gradle.kts └── src └── main ├── java └── dev │ └── lavalink │ └── youtube │ └── clients │ ├── AndroidMusicWithThumbnail.java │ ├── AndroidVrWithThumbnail.java │ ├── AndroidWithThumbnail.java │ ├── IosWithThumbnail.java │ ├── MWebWithThumbnail.java │ ├── MusicWithThumbnail.java │ ├── TvHtml5EmbeddedWithThumbnail.java │ ├── WebEmbeddedWithThumbnail.java │ ├── WebWithThumbnail.java │ └── skeleton │ ├── NonMusicClientWithThumbnail.java │ ├── ThumbnailMusicClient.java │ ├── ThumbnailNonMusicClient.java │ └── ThumbnailStreamingNonMusicClient.java └── resources └── yts-version.txt /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a new bug report for us to investigate and fix. 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: |- 8 | Join [the Discord server](https://discord.gg/ZW4s47Ppw4) for questions and discussions. 9 | If you would like to discuss new features, please select "Feature request" when creating a new issue instead. 10 | Alternatively, you can join the Discord server and discuss ideas there. 11 | - type: checkboxes 12 | attributes: 13 | label: Basic Troubleshooting 14 | description: |- 15 | Make sure you have checked the following first. 16 | Options marked with (MANDATORY) MUST be done, otherwise your issue may be closed and locked without further response. 17 | options: 18 | - label: I have checked for similar issues (open AND closed), and my issue is not a duplicate. (MANDATORY) 19 | required: true 20 | - label: I have checked for pull requests that may already address my issue. (MANDATORY) 21 | required: true 22 | - label: I am using (and my issue is reproducible on) the latest version of youtube-source. 23 | required: true 24 | - label: My issue is reproducible with IPv6 rotation. 25 | required: true 26 | - label: I DO NOT confirm that this has been filled out truthfully. 27 | required: false 28 | - type: input 29 | attributes: 30 | label: "Version of youtube-source" 31 | placeholder: "Example: 1.8.3" 32 | validations: 33 | required: true 34 | - type: input 35 | attributes: 36 | label: "The search query/queries, URL(s), playlist ID(s)/URL(s) or video ID(s)/URL(s) that triggered the issue" 37 | description: |- 38 | If a search query failed to load and threw an error, provide the entire query, including 39 | the search prefix if applicable (i.e. "ytsearch:"/"ytmsearch:"). 40 | For other identifiers, include the complete, unaltered input. 41 | If the error occurred when trying to PLAY a track (separate to LOADING), include the 42 | video ID or complete URL. 43 | If you are specifying multiple, please separate each item with a comma (item1, item2, ...) 44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: "Code Example" 49 | description: |- 50 | Provide a minimum viable code sample that we can use to reproduce your issue. 51 | Exclude any sensitive information, such as IP addresses, tokens, keys, etc. 52 | If this is not applicable, just write "N/A" 53 | render: java 54 | validations: 55 | required: true 56 | - type: textarea 57 | attributes: 58 | label: "Exception and Stacktrace" 59 | description: |- 60 | If your issue relates to an error thrown by youtube-source, please paste the error here. 61 | Include the ENTIRE stacktrace if available, as well as JSON dump if one is provided. 62 | You may redact sensitive information such as IP addresses, tokens, keys etc. 63 | If this is not applicable, just write "N/A". 64 | render: text 65 | validations: 66 | required: true 67 | - type: input 68 | attributes: 69 | label: "What is your client configuration?" 70 | description: |- 71 | Specify a complete list of clients that you are using, as per your application.yml (if using Lavalink) 72 | or constructor parameters (if invoking youtube-source directly). 73 | validations: 74 | required: true 75 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for youtube-source. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | What feature would you like to be added? Give a brief summary. 11 | 12 | What would you like this feature to do? Feel free to go into detail, explaining the finer points may help with implementing if your idea is accepted. 13 | 14 | ... 15 | -------------------------------------------------------------------------------- /.github/workflows/autocloser.yml: -------------------------------------------------------------------------------- 1 | name: Autocloser 2 | on: [issues] 3 | jobs: 4 | autoclose: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Autoclose issues that did not follow issue template 8 | uses: roots/issue-closer@v1.1 9 | with: 10 | repo-token: ${{ secrets.GITHUB_TOKEN }} 11 | issue-close-message: "@${issue.user.login} Your issue has been closed for not meeting the required criteria. Please read the bug report form in full, answering it truthfully and ensuring you have carried out the troubleshooting checks. If your report is not filled to a satisfactory level, we reserve the right to close and lock your issue without any further consideration." 12 | issue-pattern: ".*- \\[ \\]\\s+I\\s+DO\\s+NOT\\s+confirm\\s+that\\s+this\\s+has\\s+been\\s+filled\\s+out\\s+truthfully\\s*\\..*" 13 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | release: 5 | types: [ published ] 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | env: 10 | MAVEN_USERNAME: ${{ vars.MAVEN_USERNAME }} 11 | MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup Java 19 | uses: actions/setup-java@v4 20 | with: 21 | distribution: zulu 22 | java-version: 17 23 | cache: gradle 24 | 25 | - name: Setup Gradle 26 | uses: gradle/actions/setup-gradle@v4 27 | 28 | - name: Build and Publish 29 | run: ./gradlew build publish --no-daemon -PMAVEN_USERNAME=$MAVEN_USERNAME -PMAVEN_PASSWORD=$MAVEN_PASSWORD 30 | 31 | - name: Upload common Artifact 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: youtube-common.jar 35 | path: common/build/libs/youtube-common-*.jar 36 | 37 | - name: Upload lldevs Artifact 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: youtube-v2.jar 41 | path: v2/build/libs/youtube-v2-*.jar 42 | 43 | - name: Upload plugin Artifact 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: youtube-plugin.jar 47 | path: plugin/build/libs/youtube-plugin-*.jar 48 | 49 | release: 50 | needs: build 51 | runs-on: ubuntu-latest 52 | if: github.event_name == 'release' 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v4 56 | 57 | - name: Download youtube-common Artifact 58 | uses: actions/download-artifact@v4 59 | with: 60 | name: youtube-common.jar 61 | 62 | - name: Download youtube-v2 Artifact 63 | uses: actions/download-artifact@v4 64 | with: 65 | name: youtube-v2.jar 66 | 67 | - name: Download youtube-plugin Artifact 68 | uses: actions/download-artifact@v4 69 | with: 70 | name: youtube-plugin.jar 71 | 72 | - name: Upload Artifacts to GitHub Release 73 | uses: ncipollo/release-action@v1 74 | with: 75 | artifacts: '*.jar' 76 | allowUpdates: true 77 | omitBodyDuringUpdate: true 78 | omitDraftDuringUpdate: true 79 | omitNameDuringUpdate: true 80 | omitPrereleaseDuringUpdate: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | 8 | # BlueJ files 9 | *.ctxt 10 | 11 | # Mobile Tools for Java (J2ME) 12 | .mtj.tmp/ 13 | 14 | # Package Files # 15 | *.war 16 | *.nar 17 | *.ear 18 | *.zip 19 | *.tar.gz 20 | *.rar 21 | 22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 23 | hs_err_pid* 24 | replay_pid* 25 | 26 | # Custom 27 | .gradle/* 28 | .idea/* 29 | .DS_Store 30 | */build/* 31 | build 32 | .vscode/* 33 | 34 | application.yml 35 | playerconfigs/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 devoxin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.ajoberstar.grgit.Grgit 2 | 3 | plugins { 4 | java 5 | id("org.ajoberstar.grgit") version "5.2.0" 6 | alias(libs.plugins.maven.publish.base) apply false 7 | } 8 | 9 | val (gitVersion, release) = versionFromGit() 10 | logger.lifecycle("Version: $gitVersion (release: $release)") 11 | 12 | allprojects { 13 | group = "dev.lavalink.youtube" 14 | // The plugin project is the only one that should not have a snapshot version since lavalink expects the jar name to be specific 15 | version = if (project.name == "plugin") { 16 | gitVersion.removeSuffix("-SNAPSHOT") 17 | } else { 18 | gitVersion 19 | } 20 | 21 | 22 | repositories { 23 | mavenLocal() 24 | mavenCentral() 25 | maven(url = "https://maven.lavalink.dev/releases") 26 | maven(url = "https://jitpack.io") 27 | } 28 | } 29 | 30 | subprojects { 31 | apply() 32 | apply() 33 | 34 | configure { 35 | sourceCompatibility = JavaVersion.VERSION_1_8 36 | targetCompatibility = JavaVersion.VERSION_1_8 37 | } 38 | 39 | configure { 40 | val mavenUsername = findProperty("MAVEN_USERNAME") as String? 41 | val mavenPassword = findProperty("MAVEN_PASSWORD") as String? 42 | if (!mavenUsername.isNullOrEmpty() && !mavenPassword.isNullOrEmpty()) { 43 | repositories { 44 | val snapshots = "https://maven.lavalink.dev/snapshots" 45 | val releases = "https://maven.lavalink.dev/releases" 46 | 47 | maven(if (release) releases else snapshots) { 48 | credentials { 49 | username = mavenUsername 50 | password = mavenPassword 51 | } 52 | } 53 | } 54 | } else { 55 | logger.lifecycle("Not publishing to maven.lavalink.dev because credentials are not set") 56 | } 57 | } 58 | } 59 | 60 | @SuppressWarnings("GrMethodMayBeStatic") 61 | fun versionFromGit(): Pair { 62 | Grgit.open(mapOf("currentDir" to project.rootDir)).use { git -> 63 | val headTag = git.tag 64 | .list() 65 | .find { it.commit.id == git.head().id } 66 | 67 | val clean = git.status().isClean || System.getenv("CI") != null 68 | if (!clean) { 69 | logger.lifecycle("Git state is dirty, version is a snapshot.") 70 | } 71 | 72 | return if (headTag != null && clean) headTag.name to true else "${git.head().id}-SNAPSHOT" to false 73 | } 74 | } -------------------------------------------------------------------------------- /common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.JavaLibrary 2 | import com.vanniktech.maven.publish.JavadocJar 3 | import org.apache.tools.ant.filters.ReplaceTokens 4 | 5 | plugins { 6 | `java-library` 7 | alias(libs.plugins.maven.publish.base) 8 | } 9 | 10 | base { 11 | archivesName = "youtube-common" 12 | } 13 | 14 | dependencies { 15 | compileOnly(libs.lavaplayer.v1) 16 | 17 | implementation(libs.rhino.engine) 18 | implementation(libs.nanojson) 19 | compileOnly(libs.slf4j) 20 | compileOnly(libs.annotations) 21 | 22 | testImplementation(libs.lavaplayer.v1) 23 | testImplementation("org.apache.logging.log4j:log4j-core:2.19.0") 24 | testImplementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.19.0") 25 | 26 | testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.0-M1") 27 | testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.0-M1") 28 | } 29 | 30 | mavenPublishing { 31 | configure(JavaLibrary(JavadocJar.Javadoc())) 32 | } 33 | 34 | tasks { 35 | processResources { 36 | filter( 37 | "tokens" to mapOf( 38 | "version" to project.version 39 | ) 40 | ) 41 | } 42 | test { 43 | useJUnitPlatform() // Enable JUnit Platform for running JUnit 5 tests 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/CannotBeLoaded.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | public class CannotBeLoaded extends Throwable { 6 | // This is a 'cheap' exception used to tell the source manager to stop trying 7 | // to load a track as it's unloadable, e.g. if a video doesn't exist/is private etc... 8 | 9 | /** 10 | * Instantiates a new CannotBeLoaded exception to halt querying of the next clients 11 | * in the chain. 12 | * @param original The original exception that triggered this exception. 13 | */ 14 | public CannotBeLoaded(@NotNull Throwable original) { 15 | super("The URL could not be loaded.", original, false, false); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/ClientInformation.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.Client; 4 | import dev.lavalink.youtube.polyfill.DetailMessageBuilder; 5 | 6 | public class ClientInformation extends Exception { 7 | private ClientInformation(String message) { 8 | super(message, null, false, false); 9 | } 10 | 11 | public static ClientInformation create(Client client) { 12 | DetailMessageBuilder builder = new DetailMessageBuilder(); 13 | builder.appendField("yts.version", YoutubeSource.VERSION); 14 | builder.appendField("client.identifier", client.getIdentifier()); 15 | builder.appendField("client.options", client.getOptions()); 16 | return new ClientInformation(builder.toString()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/OptionDisabledException.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube; 2 | 3 | public class OptionDisabledException extends RuntimeException { 4 | public OptionDisabledException(String message) { 5 | super(message, null, true, false); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/UrlTools.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; 4 | import org.apache.http.NameValuePair; 5 | import org.apache.http.client.utils.URIBuilder; 6 | import org.jetbrains.annotations.NotNull; 7 | 8 | import java.net.URISyntaxException; 9 | import java.util.Map; 10 | import java.util.stream.Collectors; 11 | 12 | import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.COMMON; 13 | 14 | public class UrlTools { 15 | @NotNull 16 | public static UrlInfo getUrlInfo(@NotNull String url, 17 | boolean retryValidPart) { 18 | try { 19 | if (!url.startsWith("http://") && !url.startsWith("https://")) { 20 | url = "https://" + url; 21 | } 22 | 23 | URIBuilder builder = new URIBuilder(url); 24 | return new UrlInfo(builder.getPath(), builder.getQueryParams().stream() 25 | .filter(it -> it.getValue() != null) 26 | .collect(Collectors.toMap(NameValuePair::getName, NameValuePair::getValue, (a, b) -> a))); 27 | } catch (URISyntaxException e) { 28 | if (retryValidPart) { 29 | return getUrlInfo(url.substring(0, e.getIndex() - 1), false); 30 | } else { 31 | throw new FriendlyException("Not a valid URL: " + url, COMMON, e); 32 | } 33 | } 34 | } 35 | 36 | public static class UrlInfo { 37 | public final String path; 38 | public final Map parameters; 39 | 40 | private UrlInfo(@NotNull String path, 41 | @NotNull Map parameters) { 42 | this.path = path; 43 | this.parameters = parameters; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/YoutubeSource.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube; 2 | 3 | import dev.lavalink.youtube.clients.Web; 4 | import dev.lavalink.youtube.clients.WebEmbedded; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | 8 | import java.io.IOException; 9 | import java.io.InputStream; 10 | 11 | public class YoutubeSource { 12 | private static final Logger log = LoggerFactory.getLogger(YoutubeSource.class); 13 | 14 | public static String VERSION = "Unknown"; 15 | 16 | static { 17 | try (InputStream versionStream = YoutubeSource.class.getResourceAsStream("/yts-version.txt")) { 18 | if (versionStream != null) { 19 | byte[] content = new byte[versionStream.available()]; 20 | versionStream.read(content); 21 | 22 | String versionS = new String(content); 23 | 24 | if (!versionS.startsWith("@")) { 25 | VERSION = versionS; 26 | } 27 | } 28 | } catch (IOException ignored) { 29 | 30 | } 31 | } 32 | 33 | /** 34 | * Sets the given PoToken and VisitorData pair on all POT-supporting clients. 35 | * This is a convenience method to allow for setting this from one method call. 36 | * @param poToken The poToken to use. This must be paired to the specified visitorData. 37 | * You may specify {@code null} to unset. 38 | * @param visitorData The visitorData to use. This must be paired to the specified poToken. 39 | * You may specify {@code null} to unset. 40 | */ 41 | public static void setPoTokenAndVisitorData(String poToken, String visitorData) { 42 | log.debug("Applying pot: {} vd: {} to WEB, WEBEMBEDDED", poToken, visitorData); 43 | Web.setPoTokenAndVisitorData(poToken, visitorData); 44 | WebEmbedded.setPoTokenAndVisitorData(poToken, visitorData); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/YoutubeSourceOptions.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube; 2 | 3 | public class YoutubeSourceOptions { 4 | private boolean allowSearch = true; 5 | private boolean allowDirectVideoIds = true; 6 | private boolean allowDirectPlaylistIds = true; 7 | 8 | public boolean isAllowSearch() { 9 | return allowSearch; 10 | } 11 | 12 | public boolean isAllowDirectVideoIds() { 13 | return allowDirectVideoIds; 14 | } 15 | 16 | public boolean isAllowDirectPlaylistIds() { 17 | return allowDirectPlaylistIds; 18 | } 19 | 20 | public YoutubeSourceOptions setAllowSearch(boolean allowSearch) { 21 | this.allowSearch = allowSearch; 22 | return this; 23 | } 24 | 25 | public YoutubeSourceOptions setAllowDirectVideoIds(boolean allowDirectVideoIds) { 26 | this.allowDirectVideoIds = allowDirectVideoIds; 27 | return this; 28 | } 29 | 30 | public YoutubeSourceOptions setAllowDirectPlaylistIds(boolean allowDirectPlaylistIds) { 31 | this.allowDirectPlaylistIds = allowDirectPlaylistIds; 32 | return this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/cipher/CipherOperation.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.cipher; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | /** 6 | * One cipher operation definition. 7 | */ 8 | public class CipherOperation { 9 | /** 10 | * The type of the operation. 11 | */ 12 | public final CipherOperationType type; 13 | /** 14 | * The parameter for the operation. 15 | */ 16 | public final int parameter; 17 | 18 | /** 19 | * @param type The type of the operation. 20 | * @param parameter The parameter for the operation. 21 | */ 22 | public CipherOperation(@NotNull CipherOperationType type, int parameter) { 23 | this.type = type; 24 | this.parameter = parameter; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/cipher/CipherOperationType.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.cipher; 2 | 3 | /** 4 | * Type of signature cipher operation. 5 | */ 6 | public enum CipherOperationType { 7 | SWAP, 8 | REVERSE, 9 | SLICE, 10 | SPLICE 11 | } 12 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/cipher/ScriptExtractionException.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.cipher; 2 | 3 | public class ScriptExtractionException extends RuntimeException { 4 | private final ExtractionFailureType failureType; 5 | 6 | public enum ExtractionFailureType { 7 | TIMESTAMP_NOT_FOUND("timestamp"), 8 | SIG_ACTIONS_NOT_FOUND("sig actions"), 9 | DECIPHER_FUNCTION_NOT_FOUND("sig function"), 10 | N_FUNCTION_NOT_FOUND("n function"), 11 | VARIABLES_NOT_FOUND("global variables"); 12 | 13 | public final String friendlyName; 14 | 15 | ExtractionFailureType(String friendlyName) { 16 | this.friendlyName = friendlyName; 17 | } 18 | } 19 | 20 | public ScriptExtractionException(String message, ExtractionFailureType failureType) { 21 | super(message); 22 | this.failureType = failureType; 23 | } 24 | 25 | public ScriptExtractionException(String message, ExtractionFailureType failureType, Throwable cause) { 26 | super(message, cause); 27 | this.failureType = failureType; 28 | } 29 | 30 | public ExtractionFailureType getFailureType() { 31 | return failureType; 32 | } 33 | } -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/cipher/SignatureCipher.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.cipher; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import javax.script.Invocable; 8 | import javax.script.ScriptEngine; 9 | import javax.script.ScriptException; 10 | 11 | /** 12 | * Describes one signature cipher 13 | */ 14 | public class SignatureCipher { 15 | private static final Logger log = LoggerFactory.getLogger(SignatureCipher.class); 16 | 17 | public final String timestamp; 18 | public final String globalVars; 19 | public final String sigActions; 20 | public final String sigFunction; 21 | public final String nFunction; 22 | public final String rawScript; 23 | 24 | public SignatureCipher(@NotNull String timestamp, 25 | @NotNull String globalVars, 26 | @NotNull String sigActions, 27 | @NotNull String sigFunction, 28 | @NotNull String nFunction, 29 | @NotNull String rawScript) { 30 | this.timestamp = timestamp; 31 | this.globalVars = globalVars; 32 | this.sigActions = sigActions; 33 | this.sigFunction = sigFunction; 34 | this.nFunction = nFunction; 35 | this.rawScript = rawScript; 36 | } 37 | 38 | /** 39 | * @param text Text to apply the cipher on 40 | * @return The result of the cipher on the input text 41 | */ 42 | public String apply(@NotNull String text, 43 | @NotNull ScriptEngine scriptEngine) throws ScriptException, NoSuchMethodException { 44 | String transformed; 45 | 46 | scriptEngine.eval(globalVars + ";" + sigActions + ";sig=" + sigFunction); 47 | transformed = (String) ((Invocable) scriptEngine).invokeFunction("sig", text); 48 | return transformed; 49 | } 50 | 51 | // /** 52 | // * @param text Text to apply the cipher on 53 | // * @return The result of the cipher on the input text 54 | // */ 55 | // public String apply(@NotNull String text) { 56 | // StringBuilder builder = new StringBuilder(text); 57 | // 58 | // for (CipherOperation operation : operations) { 59 | // switch (operation.type) { 60 | // case SWAP: 61 | // int position = operation.parameter % text.length(); 62 | // char temp = builder.charAt(0); 63 | // builder.setCharAt(0, builder.charAt(position)); 64 | // builder.setCharAt(position, temp); 65 | // break; 66 | // case REVERSE: 67 | // builder.reverse(); 68 | // break; 69 | // case SLICE: 70 | // case SPLICE: 71 | // builder.delete(0, operation.parameter); 72 | // break; 73 | // default: 74 | // throw new IllegalStateException("All branches should be covered"); 75 | // } 76 | // } 77 | // 78 | // return builder.toString(); 79 | // } 80 | 81 | /** 82 | * @param text Text to transform 83 | * @param scriptEngine JavaScript engine to execute function 84 | * @return The result of the n parameter transformation 85 | */ 86 | public String transform(@NotNull String text, @NotNull ScriptEngine scriptEngine) 87 | throws ScriptException, NoSuchMethodException { 88 | String transformed; 89 | 90 | scriptEngine.eval(globalVars + ";n=" + nFunction); 91 | transformed = (String) ((Invocable) scriptEngine).invokeFunction("n", text); 92 | 93 | return transformed; 94 | } 95 | 96 | // /** 97 | // * @param operation The operation to add to this cipher 98 | // */ 99 | // public void addOperation(@NotNull CipherOperation operation) { 100 | // operations.add(operation); 101 | // } 102 | // 103 | // /** 104 | // * @return True if the cipher contains no operations. 105 | // */ 106 | // public boolean isEmpty() { 107 | // return operations.isEmpty(); 108 | // } 109 | } 110 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/Android.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 4 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 5 | import dev.lavalink.youtube.clients.ClientConfig.AndroidVersion; 6 | import dev.lavalink.youtube.clients.skeleton.StreamingNonMusicClient; 7 | import org.jetbrains.annotations.NotNull; 8 | import org.jetbrains.annotations.Nullable; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | public class Android extends StreamingNonMusicClient { 13 | private static final Logger log = LoggerFactory.getLogger(Android.class); 14 | 15 | public static String CLIENT_VERSION = "19.44.38"; 16 | public static AndroidVersion ANDROID_VERSION = AndroidVersion.ANDROID_11; 17 | 18 | public static ClientConfig BASE_CONFIG = new ClientConfig() 19 | .withUserAgent(String.format("com.google.android.youtube/%s (Linux; U; Android %s) gzip", CLIENT_VERSION, ANDROID_VERSION.getOsVersion())) 20 | .withClientName("ANDROID") 21 | .withClientField("clientVersion", CLIENT_VERSION) 22 | .withClientField("androidSdkVersion", ANDROID_VERSION.getSdkVersion()) 23 | .withUserField("lockedSafetyMode", false); 24 | 25 | protected ClientOptions options; 26 | 27 | public Android() { 28 | this(ClientOptions.DEFAULT); 29 | } 30 | 31 | public Android(@NotNull ClientOptions options) { 32 | this(options, true); 33 | } 34 | 35 | protected Android(@NotNull ClientOptions options, boolean logWarning) { 36 | this.options = options; 37 | 38 | if (logWarning) { 39 | log.warn("ANDROID is broken with no known fix. It is no longer advised to use this client."); 40 | } 41 | } 42 | 43 | @Override 44 | @NotNull 45 | protected ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 46 | return BASE_CONFIG.copy(); 47 | } 48 | 49 | @Override 50 | @Nullable 51 | public String getPlayerParams() { 52 | return null; 53 | } 54 | 55 | @Override 56 | @NotNull 57 | public ClientOptions getOptions() { 58 | return this.options; 59 | } 60 | 61 | @Override 62 | @NotNull 63 | public String getIdentifier() { 64 | return BASE_CONFIG.getName(); 65 | } 66 | 67 | @Override 68 | @NotNull 69 | protected String extractPlaylistName(@NotNull JsonBrowser json) { 70 | return json.get("header") 71 | .get("pageHeaderRenderer") 72 | .get("content") 73 | .get("elementRenderer") 74 | .get("newElement") 75 | .get("type") 76 | .get("componentType") 77 | .get("model") 78 | .get("youtubeModel") 79 | .get("viewModel") 80 | .get("pageHeaderViewModel") 81 | .get("title") 82 | .get("dynamicTextViewModel") 83 | .get("text") 84 | .get("content") 85 | .text(); 86 | } 87 | 88 | @Override 89 | public boolean requirePlayerScript() { 90 | return false; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/AndroidMusic.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; 5 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity; 6 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 7 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 8 | import com.sedmelluq.discord.lavaplayer.track.AudioItem; 9 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack; 10 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 11 | import org.jetbrains.annotations.NotNull; 12 | import org.jetbrains.annotations.Nullable; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import java.util.List; 17 | import java.util.Objects; 18 | import java.util.stream.Collectors; 19 | 20 | public class AndroidMusic extends Android { 21 | private static final Logger log = LoggerFactory.getLogger(AndroidMusic.class); 22 | public static String CLIENT_VERSION = "7.27.52"; 23 | 24 | public static ClientConfig BASE_CONFIG = new ClientConfig() 25 | .withClientName("ANDROID_MUSIC") 26 | .withClientField("clientVersion", CLIENT_VERSION) 27 | .withUserAgent(String.format("com.google.android.apps.youtube.music/%s (Linux; U; Android %s) gzip", CLIENT_VERSION, ANDROID_VERSION.getOsVersion())); 28 | 29 | public AndroidMusic() { 30 | this(ClientOptions.DEFAULT); 31 | } 32 | 33 | public AndroidMusic(@NotNull ClientOptions options) { 34 | super(options, false); 35 | } 36 | 37 | @Override 38 | @NotNull 39 | protected ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 40 | return BASE_CONFIG.copy(); 41 | } 42 | 43 | @Override 44 | @NotNull 45 | public String getPlayerParams() { 46 | return MOBILE_PLAYER_PARAMS; 47 | } 48 | 49 | @Override 50 | @NotNull 51 | protected JsonBrowser extractMixPlaylistData(@NotNull JsonBrowser json) { 52 | return json.get("contents") 53 | .get("singleColumnMusicWatchNextResultsRenderer") 54 | .get("tabbedRenderer") 55 | .get("watchNextTabbedResultsRenderer") 56 | .get("tabs") 57 | .values() 58 | .stream() 59 | .filter(tab -> "Up next".equalsIgnoreCase(tab.get("tabRenderer").get("title").text())) 60 | .findFirst() 61 | .orElse(json) 62 | .get("tabRenderer") 63 | .get("content") 64 | .get("musicQueueRenderer") 65 | .get("content") 66 | .get("playlistPanelRenderer"); 67 | } 68 | 69 | @NotNull 70 | protected List extractSearchResults(@NotNull YoutubeAudioSourceManager source, 71 | @NotNull JsonBrowser json) { 72 | return json.get("contents") 73 | .get("tabbedSearchResultsRenderer") 74 | .get("tabs") 75 | .values() 76 | .stream() 77 | .flatMap(item -> item.get("tabRenderer").get("content").get("sectionListRenderer").get("contents").values().stream()) 78 | .map(item -> extractAudioTrack(item.get("musicCardShelfRenderer"), source)) 79 | .filter(Objects::nonNull) 80 | .collect(Collectors.toList()); 81 | } 82 | 83 | @Override 84 | @Nullable 85 | protected AudioTrack extractAudioTrack(@NotNull JsonBrowser json, @NotNull YoutubeAudioSourceManager source) { 86 | if (json.isNull() || !json.get("unplayableText").isNull()) return null; 87 | 88 | AudioTrack track = super.extractAudioTrack(json, source); 89 | 90 | if (track != null) { 91 | return track; 92 | } 93 | 94 | String videoId = json.get("onTap").get("watchEndpoint").get("videoId").text(); 95 | 96 | if (videoId == null) { 97 | return null; 98 | } 99 | 100 | JsonBrowser titleJson = json.get("title"); 101 | JsonBrowser secondaryJson = json.get("menu").get("menuRenderer").get("title").get("musicMenuTitleRenderer").get("secondaryText").get("runs"); 102 | String title = DataFormatTools.defaultOnNull(titleJson.get("runs").index(0).get("text").text(), titleJson.get("simpleText").text()); 103 | String author = secondaryJson.index(0).get("text").text(); 104 | 105 | if (author == null) { 106 | log.debug("Author field is null, json: {}", json.format()); 107 | author = "Unknown artist"; 108 | } 109 | 110 | JsonBrowser durationJson = secondaryJson.index(2); 111 | String durationText = DataFormatTools.defaultOnNull(durationJson.get("text").text(), durationJson.get("runs").index(0).get("text").text()); 112 | 113 | long duration = DataFormatTools.durationTextToMillis(durationText); 114 | return buildAudioTrack(source, json, title, author, duration, videoId, false); 115 | } 116 | 117 | @Override 118 | public boolean canHandleRequest(@NotNull String identifier) { 119 | // loose check to avoid loading playlists. 120 | // this client does support them, but it seems to be missing fields (i.e. videoId) 121 | return (!identifier.contains("list=") || identifier.contains("list=RD")) && super.canHandleRequest(identifier); 122 | } 123 | 124 | @Override 125 | @NotNull 126 | public String getIdentifier() { 127 | return BASE_CONFIG.getName(); 128 | } 129 | 130 | @Override 131 | public AudioItem loadPlaylist(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String playlistId, @Nullable String selectedVideoId) { 132 | // It does actually return JSON, but it seems like videoId is missing. 133 | // Each video JSON contains a "Content is unavailable" message. 134 | // Theoretically, you could construct an audio track from the JSON as author, duration and title are there. 135 | // Video ID is included in the thumbnail URL, but I don't think it's worth writing parsing for. 136 | throw new FriendlyException("This client cannot load playlists", Severity.COMMON, 137 | new RuntimeException("ANDROID_MUSIC cannot be used to load playlists")); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/AndroidVr.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 4 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 5 | import dev.lavalink.youtube.clients.ClientConfig.AndroidVersion; 6 | import org.jetbrains.annotations.NotNull; 7 | 8 | public class AndroidVr extends Android { 9 | public static String CLIENT_VERSION = "1.60.19"; 10 | public static AndroidVersion ANDROID_VERSION = AndroidVersion.ANDROID_12L; 11 | 12 | public static ClientConfig BASE_CONFIG = new ClientConfig() 13 | .withUserAgent(String.format("com.google.android.apps.youtube.vr.oculus/%s (Linux; U; Android %s; eureka-user Build/SQ3A.220605.009.A1) gzip", CLIENT_VERSION, ANDROID_VERSION.getOsVersion())) 14 | .withClientName("ANDROID_VR") 15 | .withClientField("clientVersion", CLIENT_VERSION) 16 | .withClientField("androidSdkVersion", ANDROID_VERSION.getSdkVersion()); 17 | 18 | protected ClientOptions options; 19 | 20 | public AndroidVr() { 21 | this(ClientOptions.DEFAULT); 22 | } 23 | 24 | public AndroidVr(@NotNull ClientOptions options) { 25 | super(options, false); 26 | } 27 | 28 | @Override 29 | @NotNull 30 | protected ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 31 | return BASE_CONFIG.copy(); 32 | } 33 | 34 | 35 | @Override 36 | @NotNull 37 | public String getIdentifier() { 38 | return BASE_CONFIG.getName(); 39 | } 40 | 41 | @Override 42 | @NotNull 43 | protected String extractPlaylistName(@NotNull JsonBrowser json) { 44 | return json.get("header").get("playlistHeaderRenderer").get("title").get("runs").index(0).get("text").text(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/ClientConfig.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.grack.nanojson.JsonWriter; 4 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 5 | import dev.lavalink.youtube.http.YoutubeHttpContextFilter; 6 | import org.jetbrains.annotations.NotNull; 7 | import org.jetbrains.annotations.Nullable; 8 | 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | 12 | @SuppressWarnings("unchecked") 13 | public class ClientConfig { 14 | // https://github.com/MShawon/YouTube-Viewer/issues/593 15 | // root.cpn => content playback nonce, a-zA-Z0-9-_ (16 characters) 16 | // contextPlaybackContext.refer => url (video watch URL?) 17 | 18 | private String name; 19 | private String userAgent; 20 | private String visitorData; 21 | private String apiKey; 22 | private final Map root; 23 | 24 | public ClientConfig() { 25 | this.name = null; 26 | this.userAgent = null; 27 | this.visitorData = null; 28 | this.root = new HashMap<>(); 29 | } 30 | 31 | private ClientConfig(@NotNull Map context, 32 | @NotNull String userAgent, 33 | @NotNull String visitorData, 34 | @NotNull String name) { 35 | this.name = name; 36 | this.userAgent = userAgent; 37 | this.visitorData = visitorData; 38 | this.root = context; 39 | } 40 | 41 | public String getName() { 42 | return this.name; 43 | } 44 | 45 | public String getUserAgent() { 46 | return this.userAgent; 47 | } 48 | 49 | public String getVisitorData() { 50 | return this.visitorData; 51 | } 52 | 53 | public String getApiKey() { 54 | return this.apiKey; 55 | } 56 | 57 | public Map getRoot() { 58 | return this.root; 59 | } 60 | 61 | public ClientConfig copy() { 62 | return new ClientConfig(new HashMap<>(this.root), this.userAgent, this.visitorData, this.name); 63 | } 64 | 65 | public ClientConfig withClientName(@NotNull String name) { 66 | this.name = name; 67 | withClientField("clientName", name); 68 | return this; 69 | } 70 | 71 | public ClientConfig withUserAgent(@NotNull String userAgent) { 72 | this.userAgent = userAgent; 73 | return this; 74 | } 75 | 76 | public ClientConfig withVisitorData(@Nullable String visitorData) { 77 | this.visitorData = visitorData; 78 | 79 | if (visitorData != null) { 80 | withClientField("visitorData", visitorData); 81 | } else { 82 | Map context = (Map) root.get("context"); 83 | 84 | if (context != null) { 85 | Map client = (Map) context.get("client"); 86 | 87 | if (client != null) { 88 | client.remove("visitorData"); 89 | 90 | if (client.isEmpty()) { 91 | context.remove("client"); 92 | } 93 | } 94 | 95 | if (context.isEmpty()) { 96 | root.remove("context"); 97 | } 98 | } 99 | } 100 | 101 | return this; 102 | } 103 | 104 | public ClientConfig withApiKey(@NotNull String apiKey) { 105 | this.apiKey = apiKey; 106 | return this; 107 | } 108 | 109 | public Map putOnceAndJoin(@NotNull Map on, 110 | @NotNull String key) { 111 | return (Map) on.computeIfAbsent(key, __ -> new HashMap()); 112 | } 113 | 114 | public ClientConfig withClientDefaultScreenParameters() { 115 | withClientField("screenDensityFloat", 1); 116 | withClientField("screenHeightPoints", 1080); 117 | withClientField("screenPixelDensity", 1); 118 | return withClientField("screenWidthPoints", 1920); 119 | } 120 | 121 | public ClientConfig withThirdPartyEmbedUrl(@NotNull String embedUrl) { 122 | Map context = putOnceAndJoin(root, "context"); 123 | Map thirdParty = putOnceAndJoin(context, "thirdParty"); 124 | thirdParty.put("embedUrl", embedUrl); 125 | return this; 126 | } 127 | 128 | public ClientConfig withPlaybackSignatureTimestamp(@NotNull String signatureTimestamp) { 129 | Map playbackContext = putOnceAndJoin(root, "playbackContext"); 130 | Map contentPlaybackContext = putOnceAndJoin(playbackContext, "contentPlaybackContext"); 131 | contentPlaybackContext.put("signatureTimestamp", signatureTimestamp); 132 | return this; 133 | } 134 | 135 | public ClientConfig withRootField(@NotNull String key, 136 | @Nullable Object value) { 137 | root.put(key, value); 138 | return this; 139 | } 140 | 141 | public ClientConfig withClientField(@NotNull String key, 142 | @Nullable Object value) { 143 | Map context = putOnceAndJoin(root, "context"); 144 | Map client = putOnceAndJoin(context, "client"); 145 | client.put(key, value); 146 | return this; 147 | } 148 | 149 | public ClientConfig withUserField(@NotNull String key, 150 | @Nullable Object value) { 151 | Map context = putOnceAndJoin(root, "context"); 152 | Map user = putOnceAndJoin(context, "user"); 153 | user.put(key, value); 154 | return this; 155 | } 156 | 157 | public ClientConfig setAttributes(@NotNull HttpInterface httpInterface) { 158 | if (userAgent != null) { 159 | httpInterface.getContext().setAttribute(YoutubeHttpContextFilter.ATTRIBUTE_USER_AGENT_SPECIFIED, userAgent); 160 | 161 | if (visitorData != null) { 162 | httpInterface.getContext().setAttribute(YoutubeHttpContextFilter.ATTRIBUTE_VISITOR_DATA_SPECIFIED, visitorData); 163 | } 164 | } 165 | 166 | return this; 167 | } 168 | 169 | public String toJsonString() { 170 | return JsonWriter.string().object(root).done(); 171 | } 172 | 173 | public enum AndroidVersion { 174 | // https://apilevels.com/ 175 | ANDROID_13("13", 33), 176 | ANDROID_12L("12L", 32), 177 | ANDROID_12("12", 31), 178 | ANDROID_11("11", 30); 179 | 180 | private final String osVersion; 181 | private final int sdkVersion; 182 | 183 | AndroidVersion(@NotNull String osVersion, int sdkVersion) { 184 | this.osVersion = osVersion; 185 | this.sdkVersion = sdkVersion; 186 | } 187 | 188 | public String getOsVersion() { 189 | return this.osVersion; 190 | } 191 | 192 | public int getSdkVersion() { 193 | return this.sdkVersion; 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/ClientOptions.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | public class ClientOptions { 4 | public static final ClientOptions DEFAULT = new ClientOptions(); 5 | 6 | private boolean playback = true; 7 | private boolean playlistLoading = true; 8 | private boolean videoLoading = true; 9 | private boolean searching = true; 10 | 11 | public boolean getPlayback() { 12 | return this.playback; 13 | } 14 | 15 | public boolean getPlaylistLoading() { 16 | return this.playlistLoading; 17 | } 18 | 19 | public boolean getVideoLoading() { 20 | return this.videoLoading; 21 | } 22 | 23 | public boolean getSearching() { 24 | return this.searching; 25 | } 26 | 27 | public void setPlayback(boolean playback) { 28 | this.playback = playback; 29 | } 30 | 31 | public void setPlaylistLoading(boolean playlistLoading) { 32 | this.playlistLoading = playlistLoading; 33 | } 34 | 35 | public void setVideoLoading(boolean videoLoading) { 36 | this.videoLoading = videoLoading; 37 | } 38 | 39 | public void setSearching(boolean searching) { 40 | this.searching = searching; 41 | } 42 | 43 | public ClientOptions copy() { 44 | ClientOptions options = new ClientOptions(); 45 | options.setPlayback(this.playback); 46 | options.setPlaylistLoading(this.playlistLoading); 47 | options.setVideoLoading(this.videoLoading); 48 | options.setSearching(this.searching); 49 | return options; 50 | } 51 | 52 | @Override 53 | public String toString() { 54 | return String.format("ClientOptions{playback=%s, playlistLoading=%s, videoLoading=%s, searching=%s}", 55 | playback, playlistLoading, videoLoading, searching); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/ClientWithOptions.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.Client; 4 | 5 | @FunctionalInterface 6 | public interface ClientWithOptions { 7 | T create(ClientOptions options); 8 | } 9 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/Ios.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 4 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 5 | import dev.lavalink.youtube.clients.skeleton.StreamingNonMusicClient; 6 | import org.jetbrains.annotations.NotNull; 7 | 8 | public class Ios extends StreamingNonMusicClient { 9 | public static String CLIENT_VERSION = "19.45.4"; 10 | 11 | public static ClientConfig BASE_CONFIG = new ClientConfig() 12 | .withUserAgent(String.format("com.google.ios.youtube/%s (iPhone16,2; U; CPU iOS 18_1_0 like Mac OS X;)", CLIENT_VERSION)) 13 | .withClientName("IOS") 14 | .withClientField("clientVersion", CLIENT_VERSION) 15 | .withUserField("lockedSafetyMode", false); 16 | 17 | protected ClientOptions options; 18 | 19 | public Ios() { 20 | this(ClientOptions.DEFAULT); 21 | } 22 | 23 | public Ios(@NotNull ClientOptions options) { 24 | this.options = options; 25 | } 26 | 27 | @Override 28 | @NotNull 29 | protected ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 30 | return BASE_CONFIG.copy(); 31 | } 32 | 33 | @Override 34 | @NotNull 35 | protected JsonBrowser extractPlaylistVideoList(@NotNull JsonBrowser json) { 36 | return json.get("contents") 37 | .get("singleColumnBrowseResultsRenderer") 38 | .get("tabs") 39 | .index(0) 40 | .get("tabRenderer") 41 | .get("content") 42 | .get("sectionListRenderer") 43 | .get("contents") 44 | .index(0) 45 | .get("itemSectionRenderer") 46 | .get("contents") 47 | .index(0) 48 | .get("playlistVideoListRenderer"); 49 | } 50 | 51 | @Override 52 | @NotNull 53 | protected String extractPlaylistName(@NotNull JsonBrowser json) { 54 | return json.get("header") 55 | .get("pageHeaderRenderer") 56 | .get("pageTitle") 57 | .text(); 58 | } 59 | 60 | @Override 61 | @NotNull 62 | public String getPlayerParams() { 63 | return MOBILE_PLAYER_PARAMS; 64 | } 65 | 66 | @Override 67 | @NotNull 68 | public ClientOptions getOptions() { 69 | return this.options; 70 | } 71 | 72 | @Override 73 | @NotNull 74 | public String getIdentifier() { 75 | return BASE_CONFIG.getName(); 76 | } 77 | 78 | @Override 79 | public boolean requirePlayerScript() { 80 | return false; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/MWeb.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 4 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 5 | import com.sedmelluq.discord.lavaplayer.track.AudioItem; 6 | import com.sedmelluq.discord.lavaplayer.track.AudioReference; 7 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack; 8 | import com.sedmelluq.discord.lavaplayer.track.BasicAudioPlaylist; 9 | import dev.lavalink.youtube.OptionDisabledException; 10 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 11 | import org.jetbrains.annotations.NotNull; 12 | 13 | import java.util.List; 14 | import java.util.Objects; 15 | import java.util.stream.Collectors; 16 | 17 | public class MWeb extends Web { 18 | public static ClientConfig BASE_CONFIG = new ClientConfig() 19 | .withClientName("MWEB") 20 | .withClientField("clientVersion", "2.20240726.11.00"); 21 | 22 | public MWeb() { 23 | super(); 24 | } 25 | 26 | public MWeb(@NotNull ClientOptions options) { 27 | super(options); 28 | } 29 | 30 | @Override 31 | @NotNull 32 | public ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 33 | return BASE_CONFIG.copy(); 34 | } 35 | 36 | @Override 37 | @NotNull 38 | protected List extractSearchResults(@NotNull YoutubeAudioSourceManager source, 39 | @NotNull JsonBrowser json) { 40 | return json.get("contents") 41 | .get("sectionListRenderer") 42 | .get("contents") 43 | .values() // .index(0) 44 | .stream() 45 | .flatMap(item -> item.get("itemSectionRenderer").get("contents").values().stream()) // actual results 46 | .map(item -> extractAudioTrack(item.get("videoWithContextRenderer"), source)) 47 | .filter(Objects::nonNull) 48 | .collect(Collectors.toList()); 49 | } 50 | 51 | @Override 52 | @NotNull 53 | protected JsonBrowser extractMixPlaylistData(@NotNull JsonBrowser json) { 54 | return json.get("contents") 55 | .get("singleColumnWatchNextResults") 56 | .get("playlist") 57 | .get("playlist"); 58 | } 59 | 60 | @Override 61 | protected String extractPlaylistName(@NotNull JsonBrowser json) { 62 | return json.get("header") 63 | .get("pageHeaderRenderer") 64 | .get("pageTitle") 65 | .text(); 66 | } 67 | 68 | @Override 69 | @NotNull 70 | protected JsonBrowser extractPlaylistVideoList(@NotNull JsonBrowser json) { 71 | return json.get("contents") 72 | .get("singleColumnBrowseResultsRenderer") 73 | .get("tabs") 74 | .index(0) 75 | .get("tabRenderer") 76 | .get("content") 77 | .get("sectionListRenderer") 78 | .get("contents") 79 | .index(0) 80 | .get("itemSectionRenderer") 81 | .get("contents") 82 | .index(0) 83 | .get("playlistVideoListRenderer"); 84 | } 85 | 86 | @Override 87 | @NotNull 88 | public String getIdentifier() { 89 | return BASE_CONFIG.getName(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/Music.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 4 | import dev.lavalink.youtube.clients.skeleton.MusicClient; 5 | import org.jetbrains.annotations.NotNull; 6 | 7 | public class Music extends MusicClient { 8 | public static ClientConfig BASE_CONFIG = new ClientConfig() 9 | .withClientName("WEB_REMIX") 10 | .withClientField("clientVersion", "1.20240724.00.00"); 11 | 12 | protected ClientOptions options; 13 | 14 | public Music() { 15 | this(ClientOptions.DEFAULT); 16 | } 17 | 18 | public Music(@NotNull ClientOptions options) { 19 | this.options = options; 20 | } 21 | 22 | @Override 23 | @NotNull 24 | public ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 25 | return BASE_CONFIG.copy(); 26 | } 27 | 28 | @Override 29 | @NotNull 30 | public String getPlayerParams() { 31 | // This client is not used for format loading so, we don't have 32 | // any player parameters attached to it. 33 | // TODO?: This client *can* do playback, so maybe look into allowing 34 | // this client to be used in playback rotation. 35 | throw new UnsupportedOperationException(); 36 | } 37 | 38 | @Override 39 | @NotNull 40 | public ClientOptions getOptions() { 41 | return this.options; 42 | } 43 | 44 | @Override 45 | @NotNull 46 | public String getIdentifier() { 47 | return BASE_CONFIG.getName(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/Tv.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; 4 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity; 5 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 6 | import com.sedmelluq.discord.lavaplayer.track.AudioItem; 7 | import dev.lavalink.youtube.CannotBeLoaded; 8 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 9 | import dev.lavalink.youtube.clients.skeleton.StreamingNonMusicClient; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.jetbrains.annotations.Nullable; 12 | 13 | import java.io.IOException; 14 | 15 | public class Tv extends StreamingNonMusicClient { 16 | public static ClientConfig BASE_CONFIG = new ClientConfig() 17 | .withClientName("TVHTML5") 18 | .withUserAgent("Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version") 19 | .withClientField("clientVersion", "7.20250319.10.00"); 20 | 21 | protected ClientOptions options; 22 | 23 | public Tv() { 24 | this(ClientOptions.DEFAULT); 25 | } 26 | 27 | public Tv(@NotNull ClientOptions options) { 28 | this.options = options; 29 | } 30 | 31 | @Override 32 | @NotNull 33 | protected ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 34 | return BASE_CONFIG.copy(); 35 | } 36 | 37 | @Override 38 | @NotNull 39 | public String getPlayerParams() { 40 | return WEB_PLAYER_PARAMS; 41 | } 42 | 43 | @Override 44 | @NotNull 45 | public ClientOptions getOptions() { 46 | return this.options; 47 | } 48 | 49 | @Override 50 | public boolean canHandleRequest(@NotNull String identifier) { 51 | return false; 52 | } 53 | 54 | @Override 55 | public boolean supportsOAuth() { 56 | return true; 57 | } 58 | 59 | @Override 60 | @NotNull 61 | public String getIdentifier() { 62 | return BASE_CONFIG.getName(); 63 | } 64 | 65 | @Override 66 | public AudioItem loadPlaylist(@NotNull YoutubeAudioSourceManager source, 67 | @NotNull HttpInterface httpInterface, 68 | @NotNull String playlistId, 69 | @Nullable String selectedVideoId) { 70 | throw new FriendlyException("This client cannot load playlists", Severity.COMMON, 71 | new RuntimeException("TVHTML5 cannot be used to load playlists")); 72 | } 73 | 74 | @Override 75 | public AudioItem loadVideo(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String videoId) throws CannotBeLoaded, IOException { 76 | throw new FriendlyException("This client cannot load videos", Severity.COMMON, 77 | new RuntimeException("TVHTML5 cannot be used to load videos")); 78 | } 79 | 80 | @Override 81 | public AudioItem loadMix(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String mixId, @Nullable String selectedVideoId) { 82 | throw new FriendlyException("This client cannot load mixes", Severity.COMMON, 83 | new RuntimeException("TVHTML5 cannot be used to load mixes")); 84 | } 85 | 86 | @Override 87 | public AudioItem loadSearch(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String searchQuery) { 88 | throw new FriendlyException("This client cannot search", Severity.COMMON, 89 | new RuntimeException("TVHTML5 cannot be used to search")); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/TvHtml5Embedded.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; 5 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity; 6 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 7 | import com.sedmelluq.discord.lavaplayer.tools.Units; 8 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 9 | import com.sedmelluq.discord.lavaplayer.track.AudioItem; 10 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack; 11 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 12 | import dev.lavalink.youtube.clients.skeleton.StreamingNonMusicClient; 13 | import org.jetbrains.annotations.NotNull; 14 | import org.jetbrains.annotations.Nullable; 15 | 16 | import java.util.List; 17 | 18 | public class TvHtml5Embedded extends StreamingNonMusicClient { 19 | public static ClientConfig BASE_CONFIG = new ClientConfig() 20 | .withClientName("TVHTML5_SIMPLY_EMBEDDED_PLAYER") 21 | .withClientField("clientVersion", "2.0") 22 | .withThirdPartyEmbedUrl("https://www.youtube.com"); 23 | 24 | protected ClientOptions options; 25 | 26 | public TvHtml5Embedded() { 27 | this(ClientOptions.DEFAULT); 28 | } 29 | 30 | public TvHtml5Embedded(@NotNull ClientOptions options) { 31 | this.options = options; 32 | } 33 | 34 | @Override 35 | @NotNull 36 | protected ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 37 | return BASE_CONFIG.copy(); 38 | } 39 | 40 | @Override 41 | @NotNull 42 | protected JsonBrowser extractPlaylistVideoList(@NotNull JsonBrowser json) { 43 | return json.get("contents") 44 | .get("sectionListRenderer") 45 | .get("contents") 46 | .index(0) 47 | .get("playlistVideoListRenderer"); 48 | } 49 | 50 | @Override 51 | protected void extractPlaylistTracks(@NotNull JsonBrowser json, 52 | @NotNull List tracks, 53 | @NotNull YoutubeAudioSourceManager source) { 54 | if (!json.get("contents").isNull()) { 55 | json = json.get("contents"); 56 | } 57 | 58 | if (json.isNull()) { 59 | return; 60 | } 61 | 62 | for (JsonBrowser track : json.values()) { 63 | JsonBrowser item = track.get("videoRenderer"); 64 | JsonBrowser authorJson = item.get("shortBylineText"); 65 | 66 | // this client doesn't appear to receive "isPlayable" fields. 67 | // author is null -> video is region blocked 68 | if (!authorJson.isNull()) { 69 | String videoId = item.get("videoId").text(); 70 | JsonBrowser titleField = item.get("title"); 71 | String title = DataFormatTools.defaultOnNull(titleField.get("simpleText").text(), titleField.get("runs").index(0).get("text").text()); 72 | String author = DataFormatTools.defaultOnNull(authorJson.get("runs").index(0).get("text").text(), "Unknown artist"); 73 | long duration = Units.secondsToMillis(item.get("lengthSeconds").asLong(Units.DURATION_SEC_UNKNOWN)); 74 | tracks.add(buildAudioTrack(source, track, title, author, duration, videoId, false)); 75 | } 76 | } 77 | } 78 | 79 | @Override 80 | public boolean isEmbedded() { 81 | return true; 82 | } 83 | 84 | @Override 85 | @NotNull 86 | public String getPlayerParams() { 87 | return WEB_PLAYER_PARAMS; 88 | } 89 | 90 | @Override 91 | @NotNull 92 | public ClientOptions getOptions() { 93 | return this.options; 94 | } 95 | 96 | @Override 97 | public boolean canHandleRequest(@NotNull String identifier) { 98 | // loose check to avoid loading playlists. 99 | // this client does support them, but it seems to be missing fields 100 | // that could be the difference between playable and unplayable -- 101 | // notably the "isPlayable" field. 102 | // I'm also cautious of routing a lot of traffic through this client. 103 | // There is overridden code above but that's mostly just for reference. 104 | return (!identifier.contains("list=") || identifier.contains("list=RD")) && super.canHandleRequest(identifier); 105 | } 106 | 107 | @Override 108 | public boolean supportsOAuth() { 109 | return true; 110 | } 111 | 112 | @Override 113 | @NotNull 114 | public String getIdentifier() { 115 | return BASE_CONFIG.getName(); 116 | } 117 | 118 | @Override 119 | public AudioItem loadPlaylist(@NotNull YoutubeAudioSourceManager source, 120 | @NotNull HttpInterface httpInterface, 121 | @NotNull String playlistId, 122 | @Nullable String selectedVideoId) { 123 | throw new FriendlyException("This client cannot load playlists", Severity.COMMON, 124 | new RuntimeException("TVHTML5_EMBEDDED cannot be used to load playlists")); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/Web.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 5 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 6 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; 7 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 8 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack; 9 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 10 | import dev.lavalink.youtube.clients.skeleton.StreamingNonMusicClient; 11 | import org.apache.http.client.methods.CloseableHttpResponse; 12 | import org.apache.http.client.methods.HttpGet; 13 | import org.apache.http.client.utils.URIBuilder; 14 | import org.apache.http.util.EntityUtils; 15 | import org.jetbrains.annotations.NotNull; 16 | import org.jetbrains.annotations.Nullable; 17 | import org.slf4j.Logger; 18 | import org.slf4j.LoggerFactory; 19 | 20 | import java.io.IOException; 21 | import java.net.URI; 22 | import java.net.URISyntaxException; 23 | import java.util.List; 24 | import java.util.Map; 25 | import java.util.Objects; 26 | import java.util.regex.Matcher; 27 | import java.util.regex.Pattern; 28 | import java.util.stream.Collectors; 29 | 30 | public class Web extends StreamingNonMusicClient { 31 | private static final Logger log = LoggerFactory.getLogger(Web.class); 32 | 33 | protected static Pattern CONFIG_REGEX = Pattern.compile("ytcfg\\.set\\((\\{.+})\\);"); 34 | 35 | public static ClientConfig BASE_CONFIG = new ClientConfig() 36 | .withClientName("WEB") 37 | .withClientField("clientVersion", "2.20250403.01.00") 38 | .withUserField("lockedSafetyMode", false); 39 | 40 | public static String poToken; 41 | 42 | protected volatile long lastConfigUpdate = -1; 43 | 44 | protected ClientOptions options; 45 | 46 | public Web() { 47 | this(ClientOptions.DEFAULT); 48 | } 49 | 50 | public Web(@NotNull ClientOptions options) { 51 | this.options = options; 52 | } 53 | 54 | public static void setPoTokenAndVisitorData(String poToken, String visitorData) { 55 | Web.poToken = poToken; 56 | 57 | if (poToken == null || visitorData == null) { 58 | BASE_CONFIG.getRoot().remove("serviceIntegrityDimensions"); 59 | BASE_CONFIG.withVisitorData(null); 60 | return; 61 | } 62 | 63 | Map sid = BASE_CONFIG.putOnceAndJoin(BASE_CONFIG.getRoot(), "serviceIntegrityDimensions"); 64 | sid.put("poToken", poToken); 65 | BASE_CONFIG.withVisitorData(visitorData); 66 | } 67 | 68 | protected void fetchClientConfig(@NotNull HttpInterface httpInterface) { 69 | try (CloseableHttpResponse response = httpInterface.execute(new HttpGet("https://www.youtube.com"))) { 70 | HttpClientTools.assertSuccessWithContent(response, "client config fetch"); 71 | lastConfigUpdate = System.currentTimeMillis(); 72 | 73 | String page = EntityUtils.toString(response.getEntity()); 74 | Matcher m = CONFIG_REGEX.matcher(page); 75 | 76 | if (!m.find()) { 77 | log.warn("Unable to find youtube client config in base page, html: {}", page); 78 | return; 79 | } 80 | 81 | JsonBrowser json = JsonBrowser.parse(m.group(1)); 82 | JsonBrowser client = json.get("INNERTUBE_CONTEXT").get("client"); 83 | String apiKey = json.get("INNERTUBE_API_KEY").text(); 84 | 85 | if (!apiKey.isEmpty()) { 86 | BASE_CONFIG.withApiKey(apiKey); 87 | } 88 | 89 | if (!client.isNull()) { 90 | /* 91 | * "client": { 92 | * "hl": "en-GB", 93 | * "gl": "GB", 94 | * "remoteHost": "", 95 | * "deviceMake": "", 96 | * "deviceModel": "", 97 | * "visitorData": "", 98 | * "userAgent": "...", 99 | * "clientName": "WEB", 100 | * "clientVersion": "2.20240401.05.00", 101 | * "osVersion": "", 102 | * "originalUrl": "https://www.youtube.com/", 103 | * "platform": "DESKTOP", 104 | * "clientFormFactor": "UNKNOWN_FORM_FACTOR", 105 | * ... 106 | */ 107 | String clientVersion = client.get("clientVersion").text(); 108 | 109 | if (!clientVersion.isEmpty()) { 110 | // overwrite baseConfig version so we're always up-to-date 111 | BASE_CONFIG.withClientField("clientVersion", clientVersion); 112 | } 113 | 114 | // String visitorData = client.get("visitorData").text(); 115 | // 116 | // if (visitorData != null && !visitorData.isEmpty()) { 117 | // BASE_CONFIG.withVisitorData(visitorData); 118 | // } 119 | } 120 | } catch (IOException e) { 121 | throw ExceptionTools.toRuntimeException(e); 122 | } 123 | } 124 | 125 | @Override 126 | @NotNull 127 | public ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 128 | if (lastConfigUpdate == -1) { 129 | synchronized (this) { 130 | if (lastConfigUpdate == -1) { 131 | fetchClientConfig(httpInterface); 132 | } 133 | } 134 | } 135 | 136 | return BASE_CONFIG.copy(); 137 | } 138 | 139 | @Override 140 | @NotNull 141 | public URI transformPlaybackUri(@NotNull URI originalUri, @NotNull URI resolvedPlaybackUri) { 142 | if (poToken == null) { 143 | return resolvedPlaybackUri; 144 | } 145 | 146 | log.debug("Applying 'pot' parameter on playback URI: {}", resolvedPlaybackUri); 147 | URIBuilder builder = new URIBuilder(resolvedPlaybackUri); 148 | builder.addParameter("pot", poToken); 149 | 150 | try { 151 | return builder.build(); 152 | } catch (URISyntaxException e) { 153 | log.debug("Failed to apply 'pot' parameter.", e); 154 | return resolvedPlaybackUri; 155 | } 156 | } 157 | 158 | @Override 159 | @NotNull 160 | protected List extractSearchResults(@NotNull YoutubeAudioSourceManager source, 161 | @NotNull JsonBrowser json) { 162 | return json.get("contents") 163 | .get("twoColumnSearchResultsRenderer") 164 | .get("primaryContents") 165 | .get("sectionListRenderer") 166 | .get("contents") 167 | .values() // .index(0) 168 | .stream() 169 | .flatMap(item -> item.get("itemSectionRenderer").get("contents").values().stream()) // actual results 170 | .map(item -> extractAudioTrack(item.get("videoRenderer"), source)) 171 | .filter(Objects::nonNull) 172 | .collect(Collectors.toList()); 173 | } 174 | 175 | @Override 176 | @NotNull 177 | protected JsonBrowser extractMixPlaylistData(@NotNull JsonBrowser json) { 178 | return json.get("contents") 179 | .get("twoColumnWatchNextResults") 180 | .get("playlist") // this doesn't exist if mix is not found 181 | .get("playlist"); 182 | } 183 | 184 | @Override 185 | protected String extractPlaylistName(@NotNull JsonBrowser json) { 186 | return json.get("metadata").get("playlistMetadataRenderer").get("title").text(); 187 | } 188 | 189 | @NotNull 190 | protected JsonBrowser extractPlaylistVideoList(@NotNull JsonBrowser json) { 191 | return json.get("contents") 192 | .get("twoColumnBrowseResultsRenderer") 193 | .get("tabs") 194 | .index(0) 195 | .get("tabRenderer") 196 | .get("content") 197 | .get("sectionListRenderer") 198 | .get("contents") 199 | .index(0) 200 | .get("itemSectionRenderer") 201 | .get("contents") 202 | .index(0) 203 | .get("playlistVideoListRenderer"); 204 | } 205 | 206 | @Override 207 | @Nullable 208 | protected String extractPlaylistContinuationToken(@NotNull JsonBrowser videoList) { 209 | // WEB continuations seem to be slightly inconsistent. 210 | JsonBrowser contents = videoList.get("contents"); 211 | 212 | if (!contents.isNull()) { 213 | videoList = contents; 214 | } 215 | 216 | return videoList.values() 217 | .stream() 218 | .filter(item -> !item.get("continuationItemRenderer").isNull()) 219 | .findFirst() 220 | .map(item -> { 221 | JsonBrowser continuationEndpoint = item.get("continuationItemRenderer").get("continuationEndpoint"); 222 | String token = continuationEndpoint.get("continuationCommand").get("token").text(); 223 | if (!DataFormatTools.isNullOrEmpty(token)) { 224 | return token; 225 | } 226 | 227 | return continuationEndpoint.get("commandExecutorCommand").get("commands").index(1) 228 | .get("continuationCommand").get("token").text(); 229 | }) 230 | .orElse(null); 231 | } 232 | 233 | @Override 234 | @NotNull 235 | protected JsonBrowser extractPlaylistContinuationVideos(@NotNull JsonBrowser continuationJson) { 236 | return continuationJson.get("onResponseReceivedActions") 237 | .index(0) 238 | .get("appendContinuationItemsAction") 239 | .get("continuationItems"); 240 | } 241 | 242 | @Override 243 | @NotNull 244 | public String getPlayerParams() { 245 | return WEB_PLAYER_PARAMS; 246 | } 247 | 248 | @Override 249 | @NotNull 250 | public ClientOptions getOptions() { 251 | return this.options; 252 | } 253 | 254 | @Override 255 | @NotNull 256 | public String getIdentifier() { 257 | return BASE_CONFIG.getName(); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/WebEmbedded.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; 4 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity; 5 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 6 | import com.sedmelluq.discord.lavaplayer.track.AudioItem; 7 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 8 | import org.jetbrains.annotations.NotNull; 9 | import org.jetbrains.annotations.Nullable; 10 | import java.net.URISyntaxException; 11 | import java.util.Map; 12 | import org.apache.http.client.utils.URIBuilder; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | import java.net.URI; 16 | 17 | public class WebEmbedded extends Web { 18 | private static final Logger log = LoggerFactory.getLogger(WebEmbedded.class); 19 | 20 | public static ClientConfig BASE_CONFIG = new ClientConfig() 21 | .withClientName("WEB_EMBEDDED_PLAYER") 22 | .withClientField("clientVersion", "1.20250401.01.00") 23 | .withUserField("lockedSafetyMode", false); 24 | 25 | public WebEmbedded() { 26 | super(ClientOptions.DEFAULT); 27 | } 28 | 29 | public static void setPoTokenAndVisitorData(String poToken, String visitorData) { 30 | WebEmbedded.poToken = poToken; 31 | 32 | if (poToken == null || visitorData == null) { 33 | BASE_CONFIG.getRoot().remove("serviceIntegrityDimensions"); 34 | BASE_CONFIG.withVisitorData(null); 35 | return; 36 | } 37 | 38 | Map sid = BASE_CONFIG.putOnceAndJoin(BASE_CONFIG.getRoot(), "serviceIntegrityDimensions"); 39 | sid.put("poToken", poToken); 40 | BASE_CONFIG.withVisitorData(visitorData); 41 | } 42 | 43 | @Override 44 | public boolean isEmbedded() { 45 | return true; 46 | } 47 | 48 | @Override 49 | @NotNull 50 | public URI transformPlaybackUri(@NotNull URI originalUri, @NotNull URI resolvedPlaybackUri) { 51 | if (poToken == null) { 52 | return resolvedPlaybackUri; 53 | } 54 | 55 | log.debug("Applying 'pot' parameter on playback URI: {}", resolvedPlaybackUri); 56 | URIBuilder builder = new URIBuilder(resolvedPlaybackUri); 57 | builder.addParameter("pot", poToken); 58 | 59 | try { 60 | return builder.build(); 61 | } catch (URISyntaxException e) { 62 | log.debug("Failed to apply 'pot' parameter.", e); 63 | return resolvedPlaybackUri; 64 | } 65 | } 66 | 67 | public WebEmbedded(@NotNull ClientOptions options) { 68 | super(options); 69 | } 70 | 71 | @Override 72 | @NotNull 73 | public ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) { 74 | return BASE_CONFIG.copy(); 75 | } 76 | @Override 77 | @NotNull 78 | public String getIdentifier() { 79 | return BASE_CONFIG.getName(); 80 | } 81 | 82 | @Override 83 | public boolean canHandleRequest(@NotNull String identifier) { 84 | return !identifier.startsWith(YoutubeAudioSourceManager.SEARCH_PREFIX) && !identifier.contains("list=") && super.canHandleRequest(identifier); 85 | } 86 | 87 | @Override 88 | public AudioItem loadSearch(@NotNull YoutubeAudioSourceManager source, 89 | @NotNull HttpInterface httpInterface, 90 | @NotNull String searchQuery) { 91 | throw new FriendlyException("This client cannot load searches", Severity.COMMON, 92 | new RuntimeException("WEBEMBEDDED cannot be used to load searches")); 93 | } 94 | 95 | @Override 96 | public AudioItem loadPlaylist(@NotNull YoutubeAudioSourceManager source, 97 | @NotNull HttpInterface httpInterface, 98 | @NotNull String playlistId, 99 | @Nullable String selectedVideoId) { 100 | throw new FriendlyException("This client cannot load playlists", Severity.COMMON, 101 | new RuntimeException("WEBEMBEDDED cannot be used to load playlists")); 102 | } 103 | 104 | @Override 105 | public AudioItem loadMix(@NotNull YoutubeAudioSourceManager source, 106 | @NotNull HttpInterface httpInterface, 107 | @NotNull String mixId, 108 | @Nullable String selectedVideoId) { 109 | throw new FriendlyException("This client cannot load mixes", Severity.COMMON, 110 | new RuntimeException("WEBEMBEDDED cannot be used to load mixes")); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/skeleton/MusicClient.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients.skeleton; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools; 5 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 6 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; 7 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 8 | import com.sedmelluq.discord.lavaplayer.track.*; 9 | import dev.lavalink.youtube.OptionDisabledException; 10 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 11 | import dev.lavalink.youtube.clients.ClientConfig; 12 | import dev.lavalink.youtube.track.format.TrackFormats; 13 | import org.apache.http.client.methods.CloseableHttpResponse; 14 | import org.apache.http.client.methods.HttpPost; 15 | import org.apache.http.entity.StringEntity; 16 | import org.jetbrains.annotations.NotNull; 17 | import org.jetbrains.annotations.Nullable; 18 | import org.slf4j.Logger; 19 | import org.slf4j.LoggerFactory; 20 | 21 | import java.io.IOException; 22 | import java.util.ArrayList; 23 | import java.util.List; 24 | 25 | /** 26 | * The base class for a client that can be used with music.youtube.com. 27 | */ 28 | public abstract class MusicClient implements Client { 29 | private static final Logger log = LoggerFactory.getLogger(MusicClient.class); 30 | 31 | @NotNull 32 | protected abstract ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface); 33 | 34 | protected JsonBrowser getMusicSearchResult(@NotNull HttpInterface httpInterface, 35 | @NotNull String searchQuery) { 36 | ClientConfig config = getBaseClientConfig(httpInterface) 37 | .withRootField("query", searchQuery) 38 | .withRootField("params", MUSIC_SEARCH_PARAMS) 39 | .setAttributes(httpInterface); 40 | 41 | HttpPost request = new HttpPost(MUSIC_SEARCH_URL); 42 | request.setEntity(new StringEntity(config.toJsonString(), "UTF-8")); 43 | request.setHeader("Referer", "music.youtube.com"); 44 | 45 | try (CloseableHttpResponse response = httpInterface.execute(request)) { 46 | HttpClientTools.assertSuccessWithContent(response, "search music response"); 47 | return JsonBrowser.parse(response.getEntity().getContent()); 48 | } catch (IOException e) { 49 | throw ExceptionTools.toRuntimeException(e); 50 | } 51 | } 52 | 53 | protected JsonBrowser extractSearchResultTrackJson(@NotNull JsonBrowser json) { 54 | return json.get("contents") 55 | .get("tabbedSearchResultsRenderer") 56 | .get("tabs") 57 | .index(0) 58 | .get("tabRenderer") 59 | .get("content") 60 | .get("sectionListRenderer") 61 | .get("contents") 62 | .values() 63 | .stream() 64 | .filter(item -> !item.get("musicShelfRenderer").isNull()) 65 | .findFirst() 66 | .map(item -> item.get("musicShelfRenderer").get("contents")) 67 | .orElse(JsonBrowser.NULL_BROWSER); 68 | } 69 | 70 | @NotNull 71 | protected List extractSearchResultTracks(@NotNull YoutubeAudioSourceManager source, 72 | @NotNull JsonBrowser json) { 73 | List tracks = new ArrayList<>(); 74 | 75 | for (JsonBrowser track : json.values()) { 76 | JsonBrowser columns = track.get("musicResponsiveListItemRenderer").get("flexColumns"); 77 | 78 | if (columns.isNull()) { 79 | continue; 80 | } 81 | 82 | JsonBrowser metadata = columns.index(0) 83 | .get("musicResponsiveListItemFlexColumnRenderer") 84 | .get("text") 85 | .get("runs") 86 | .index(0); 87 | 88 | String title = metadata.get("text").text(); 89 | String videoId = metadata.get("navigationEndpoint").get("watchEndpoint").get("videoId").text(); 90 | 91 | if (videoId == null) { 92 | // If the track is not available on YouTube Music, videoId will be empty 93 | continue; 94 | } 95 | 96 | List runs = columns.index(1) 97 | .get("musicResponsiveListItemFlexColumnRenderer") 98 | .get("text") 99 | .get("runs") 100 | .values(); 101 | 102 | String author = runs.get(0).get("text").text(); 103 | 104 | if (author == null) { 105 | log.debug("Author field is null, client: {}, json: {}", getIdentifier(), json.format()); 106 | author = "Unknown artist"; 107 | } 108 | 109 | JsonBrowser lastElement = runs.get(runs.size() - 1); 110 | 111 | if (!lastElement.get("navigationEndpoint").isNull()) { 112 | // The duration element should not have this key. If it does, 113 | // then duration is probably missing. 114 | continue; 115 | } 116 | 117 | long duration = DataFormatTools.durationTextToMillis(lastElement.get("text").text()); 118 | tracks.add(buildAudioTrack(source, track, title, author, duration, videoId, false)); 119 | } 120 | 121 | return tracks; 122 | } 123 | 124 | @Override 125 | public boolean canHandleRequest(@NotNull String identifier) { 126 | return identifier.startsWith(YoutubeAudioSourceManager.MUSIC_SEARCH_PREFIX) && getOptions().getSearching(); 127 | } 128 | 129 | @Override 130 | public void setPlaylistPageCount(int count) { 131 | // nothing to do. 132 | } 133 | 134 | @Override 135 | public boolean supportsFormatLoading() { 136 | return false; 137 | } 138 | 139 | @Override 140 | public AudioItem loadSearchMusic(@NotNull YoutubeAudioSourceManager source, 141 | @NotNull HttpInterface httpInterface, 142 | @NotNull String searchQuery) { 143 | if (!getOptions().getSearching()) { 144 | // why would you even disable searching for this client lol 145 | throw new OptionDisabledException("Searching is disabled for this client"); 146 | } 147 | 148 | JsonBrowser json = getMusicSearchResult(httpInterface, searchQuery); 149 | JsonBrowser trackJson = extractSearchResultTrackJson(json); 150 | List tracks = extractSearchResultTracks(source, trackJson); 151 | 152 | if (tracks.isEmpty()) { 153 | return AudioReference.NO_TRACK; 154 | } 155 | 156 | return new BasicAudioPlaylist("Search music results for: " + searchQuery, tracks, null, true); 157 | } 158 | 159 | @Override 160 | public TrackFormats loadFormats(@NotNull YoutubeAudioSourceManager source, 161 | @NotNull HttpInterface httpInterface, 162 | @NotNull String videoId) { 163 | throw new UnsupportedOperationException(); 164 | } 165 | 166 | @Override 167 | public AudioItem loadVideo(@NotNull YoutubeAudioSourceManager source, 168 | @NotNull HttpInterface httpInterface, 169 | @NotNull String videoId) { 170 | throw new UnsupportedOperationException(); 171 | } 172 | 173 | @Override 174 | public AudioItem loadSearch(@NotNull YoutubeAudioSourceManager source, 175 | @NotNull HttpInterface httpInterface, 176 | @NotNull String searchQuery) { 177 | throw new UnsupportedOperationException(); 178 | } 179 | 180 | @Override 181 | public AudioItem loadMix(@NotNull YoutubeAudioSourceManager source, 182 | @NotNull HttpInterface httpInterface, 183 | @NotNull String mixId, 184 | @Nullable String selectedVideoId) { 185 | throw new UnsupportedOperationException(); 186 | } 187 | 188 | @Override 189 | public AudioItem loadPlaylist(@NotNull YoutubeAudioSourceManager source, 190 | @NotNull HttpInterface httpInterface, 191 | @NotNull String playlistId, 192 | @Nullable String selectedVideoId) { 193 | throw new UnsupportedOperationException(); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/clients/skeleton/StreamingNonMusicClient.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients.skeleton; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 5 | import com.sedmelluq.discord.lavaplayer.tools.Units; 6 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 7 | import dev.lavalink.youtube.CannotBeLoaded; 8 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 9 | import dev.lavalink.youtube.cipher.SignatureCipherManager.CachedPlayerScript; 10 | import dev.lavalink.youtube.track.format.StreamFormat; 11 | import dev.lavalink.youtube.track.format.TrackFormats; 12 | import org.apache.http.entity.ContentType; 13 | import org.jetbrains.annotations.NotNull; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | 17 | import java.io.IOException; 18 | import java.util.ArrayList; 19 | import java.util.Collections; 20 | import java.util.List; 21 | import java.util.Map; 22 | 23 | import static com.sedmelluq.discord.lavaplayer.tools.DataFormatTools.decodeUrlEncodedItems; 24 | import static com.sedmelluq.discord.lavaplayer.tools.Units.CONTENT_LENGTH_UNKNOWN; 25 | 26 | public abstract class StreamingNonMusicClient extends NonMusicClient { 27 | private static final Logger log = LoggerFactory.getLogger(StreamingNonMusicClient.class); 28 | 29 | protected static String DEFAULT_SIGNATURE_KEY = "signature"; 30 | 31 | @Override 32 | public TrackFormats loadFormats(@NotNull YoutubeAudioSourceManager source, 33 | @NotNull HttpInterface httpInterface, 34 | @NotNull String videoId) throws CannotBeLoaded, IOException { 35 | JsonBrowser json = loadTrackInfoFromInnertube(source, httpInterface, videoId, null, true); 36 | JsonBrowser playabilityStatus = json.get("playabilityStatus"); 37 | JsonBrowser videoDetails = json.get("videoDetails"); 38 | CachedPlayerScript playerScript = source.getCipherManager().getCachedPlayerScript(httpInterface); 39 | 40 | boolean isLive = videoDetails.get("isLive").asBoolean(false); 41 | 42 | if ("OK".equals(playabilityStatus.get("status").text()) && playabilityStatus.get("reason").safeText().contains("This live event has ended")) { 43 | // Long videos after ending of stream don't contain contentLength field as they 44 | // are still being processed by YouTube. 45 | isLive = true; 46 | } 47 | 48 | JsonBrowser streamingData = json.get("streamingData"); 49 | JsonBrowser mergedFormats = streamingData.get("formats"); 50 | JsonBrowser adaptiveFormats = streamingData.get("adaptiveFormats"); 51 | 52 | List formats = new ArrayList<>(); 53 | boolean anyFailures = false; 54 | 55 | for (JsonBrowser merged : mergedFormats.values()) { 56 | if (!extractFormat(merged, formats, isLive)) { 57 | anyFailures = true; 58 | } 59 | } 60 | 61 | for (JsonBrowser adaptive : adaptiveFormats.values()) { 62 | if (!extractFormat(adaptive, formats, isLive)) { 63 | anyFailures = true; 64 | } 65 | } 66 | 67 | if (formats.isEmpty() && anyFailures) { 68 | log.warn("Loading formats either failed to load or were skipped due to missing fields, json: {}", streamingData.format()); 69 | } 70 | 71 | return new TrackFormats(formats, playerScript.url); 72 | } 73 | 74 | protected boolean extractFormat(JsonBrowser formatJson, 75 | List formats, 76 | boolean isLive) { 77 | if (formatJson.isNull() || !formatJson.isMap()) { 78 | return false; 79 | } 80 | 81 | String url = formatJson.get("url").text(); 82 | String cipher = formatJson.get("signatureCipher").text(); 83 | 84 | Map cipherInfo = cipher != null 85 | ? decodeUrlEncodedItems(cipher, true) 86 | : Collections.emptyMap(); 87 | 88 | if (DataFormatTools.isNullOrEmpty(url) && DataFormatTools.isNullOrEmpty(cipherInfo.get("url"))) { 89 | log.debug("Client '{}' is missing format URL for itag '{}'. SABR response?", getIdentifier(), formatJson.get("itag").text()); 90 | return false; 91 | } 92 | 93 | Map urlMap = DataFormatTools.isNullOrEmpty(url) 94 | ? decodeUrlEncodedItems(cipherInfo.get("url"), false) 95 | : decodeUrlEncodedItems(url, false); 96 | 97 | try { 98 | long contentLength = formatJson.get("contentLength").asLong(CONTENT_LENGTH_UNKNOWN); 99 | int itag = (int) formatJson.get("itag").asLong(-1); 100 | 101 | // itag 18 is a legacy format which doesn't have a (valid) content length field. 102 | if (contentLength == CONTENT_LENGTH_UNKNOWN && !isLive && itag != 18) { 103 | log.debug("Track is not a live stream, but no contentLength in format {}, skipping", formatJson.format()); 104 | return true; // this isn't considered fatal. 105 | } 106 | 107 | formats.add(new StreamFormat( 108 | ContentType.parse(formatJson.get("mimeType").text()), 109 | itag, 110 | formatJson.get("bitrate").asLong(Units.BITRATE_UNKNOWN), 111 | contentLength, 112 | formatJson.get("audioChannels").asLong(2), 113 | cipherInfo.getOrDefault("url", url), 114 | urlMap.get("n"), 115 | cipherInfo.get("s"), 116 | cipherInfo.getOrDefault("sp", DEFAULT_SIGNATURE_KEY), 117 | formatJson.get("audioTrack").get("audioIsDefault").asBoolean(true), 118 | formatJson.get("isDrc").asBoolean(false) 119 | )); 120 | 121 | return true; 122 | } catch (RuntimeException e) { 123 | log.debug("Failed to parse format {}, skipping", formatJson, e); 124 | return false; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/http/BaseYoutubeHttpContextFilter.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.http; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.http.HttpContextFilter; 4 | import org.apache.http.HttpResponse; 5 | import org.apache.http.client.methods.HttpUriRequest; 6 | import org.apache.http.client.protocol.HttpClientContext; 7 | 8 | public class BaseYoutubeHttpContextFilter implements HttpContextFilter { 9 | @Override 10 | public void onContextOpen(HttpClientContext context) { 11 | 12 | } 13 | 14 | @Override 15 | public void onContextClose(HttpClientContext context) { 16 | 17 | } 18 | 19 | @Override 20 | public void onRequest(HttpClientContext context, 21 | HttpUriRequest request, 22 | boolean isRepetition) { 23 | // Consent cookie, so we do not land on consent page for HTML requests 24 | request.addHeader("Cookie", "CONSENT=YES+cb.20210328-17-p0.en+FX+471"); 25 | } 26 | 27 | @Override 28 | public boolean onRequestResponse(HttpClientContext context, 29 | HttpUriRequest request, 30 | HttpResponse response) { 31 | return false; 32 | } 33 | 34 | @Override 35 | public boolean onRequestException(HttpClientContext context, 36 | HttpUriRequest request, 37 | Throwable error) { 38 | return false; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/http/YoutubeAccessTokenTracker.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.http; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 4 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; 5 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 6 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager; 7 | import dev.lavalink.youtube.clients.Android; 8 | import dev.lavalink.youtube.clients.ClientConfig; 9 | import org.apache.http.client.methods.CloseableHttpResponse; 10 | import org.apache.http.client.methods.HttpPost; 11 | import org.apache.http.client.protocol.HttpClientContext; 12 | import org.apache.http.entity.StringEntity; 13 | import org.jetbrains.annotations.NotNull; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | 17 | import java.io.IOException; 18 | import java.util.concurrent.TimeUnit; 19 | 20 | public class YoutubeAccessTokenTracker { 21 | private static final Logger log = LoggerFactory.getLogger(YoutubeAccessTokenTracker.class); 22 | 23 | private static final String TOKEN_FETCH_CONTEXT_ATTRIBUTE = "yt-raw"; 24 | private static final long VISITOR_ID_REFRESH_INTERVAL = TimeUnit.MINUTES.toMillis(10); 25 | 26 | private final Object tokenLock = new Object(); 27 | private final HttpInterfaceManager httpInterfaceManager; 28 | private String visitorId; 29 | private long lastVisitorIdUpdate; 30 | 31 | public YoutubeAccessTokenTracker(@NotNull HttpInterfaceManager httpInterfaceManager) { 32 | this.httpInterfaceManager = httpInterfaceManager; 33 | } 34 | 35 | /** 36 | * Updates the visitor id if more than {@link #VISITOR_ID_REFRESH_INTERVAL} time has passed since last updated. 37 | */ 38 | public String getVisitorId() { 39 | long now = System.currentTimeMillis(); 40 | 41 | if (visitorId == null || now - lastVisitorIdUpdate < VISITOR_ID_REFRESH_INTERVAL) { 42 | synchronized (tokenLock) { 43 | if (now - lastVisitorIdUpdate < VISITOR_ID_REFRESH_INTERVAL) { 44 | log.debug("YouTube visitor id was recently updated, not updating again right away."); 45 | return visitorId; 46 | } 47 | 48 | lastVisitorIdUpdate = now; 49 | 50 | try { 51 | visitorId = fetchVisitorId(); 52 | log.info("Updating YouTube visitor id succeeded, new one is {}, next update will be after {} seconds.", 53 | visitorId, 54 | TimeUnit.MILLISECONDS.toSeconds(VISITOR_ID_REFRESH_INTERVAL) 55 | ); 56 | } catch (Exception e) { 57 | log.error("YouTube visitor id update failed.", e); 58 | } 59 | } 60 | } 61 | 62 | return visitorId; 63 | } 64 | 65 | public boolean isTokenFetchContext(@NotNull HttpClientContext context) { 66 | return context.getAttribute(TOKEN_FETCH_CONTEXT_ATTRIBUTE) == Boolean.TRUE; 67 | } 68 | 69 | private String fetchVisitorId() throws IOException { 70 | try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) { 71 | httpInterface.getContext().setAttribute(TOKEN_FETCH_CONTEXT_ATTRIBUTE, true); 72 | 73 | ClientConfig client = Android.BASE_CONFIG.setAttributes(httpInterface); 74 | 75 | HttpPost visitorIdPost = new HttpPost("https://youtubei.googleapis.com/youtubei/v1/visitor_id"); 76 | visitorIdPost.setEntity(new StringEntity(client.toJsonString(), "UTF-8")); 77 | 78 | try (CloseableHttpResponse response = httpInterface.execute(visitorIdPost)) { 79 | HttpClientTools.assertSuccessWithContent(response, "youtube visitor id"); 80 | JsonBrowser json = JsonBrowser.parse(response.getEntity().getContent()); 81 | return json.get("responseContext").get("visitorData").text(); 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.http; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; 4 | import com.sedmelluq.discord.lavaplayer.tools.http.HttpContextRetryCounter; 5 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; 6 | import dev.lavalink.youtube.clients.skeleton.Client; 7 | import org.apache.http.HttpResponse; 8 | import org.apache.http.client.CookieStore; 9 | import org.apache.http.client.methods.HttpUriRequest; 10 | import org.apache.http.client.protocol.HttpClientContext; 11 | import org.apache.http.impl.client.BasicCookieStore; 12 | import org.jetbrains.annotations.NotNull; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.COMMON; 17 | import static dev.lavalink.youtube.http.YoutubeOauth2Handler.OAUTH_INJECT_CONTEXT_ATTRIBUTE; 18 | 19 | public class YoutubeHttpContextFilter extends BaseYoutubeHttpContextFilter { 20 | private static final Logger log = LoggerFactory.getLogger(YoutubeHttpContextFilter.class); 21 | 22 | private static final String ATTRIBUTE_RESET_RETRY = "isResetRetry"; 23 | public static final String ATTRIBUTE_USER_AGENT_SPECIFIED = "clientUserAgent"; 24 | public static final String ATTRIBUTE_VISITOR_DATA_SPECIFIED = "clientVisitorData"; 25 | 26 | private static final HttpContextRetryCounter retryCounter = new HttpContextRetryCounter("yt-token-retry"); 27 | 28 | private YoutubeAccessTokenTracker tokenTracker; 29 | private YoutubeOauth2Handler oauth2Handler; 30 | 31 | public void setTokenTracker(@NotNull YoutubeAccessTokenTracker tokenTracker) { 32 | this.tokenTracker = tokenTracker; 33 | } 34 | 35 | public void setOauth2Handler(@NotNull YoutubeOauth2Handler oauth2Handler) { 36 | this.oauth2Handler = oauth2Handler; 37 | } 38 | 39 | @Override 40 | public void onContextOpen(HttpClientContext context) { 41 | CookieStore cookieStore = context.getCookieStore(); 42 | 43 | if (cookieStore == null) { 44 | cookieStore = new BasicCookieStore(); 45 | context.setCookieStore(cookieStore); 46 | } 47 | 48 | // Reset cookies for each sequence of requests. 49 | cookieStore.clear(); 50 | } 51 | 52 | @Override 53 | public void onRequest(HttpClientContext context, 54 | HttpUriRequest request, 55 | boolean isRepetition) { 56 | if (!isRepetition) { 57 | context.removeAttribute(ATTRIBUTE_RESET_RETRY); 58 | } 59 | 60 | retryCounter.handleUpdate(context, isRepetition); 61 | 62 | if (tokenTracker.isTokenFetchContext(context)) { 63 | // Used for fetching visitor id, let's not recurse. 64 | return; 65 | } 66 | 67 | if (oauth2Handler.isOauthFetchContext(context)) { 68 | return; 69 | } 70 | 71 | String userAgent = context.getAttribute(ATTRIBUTE_USER_AGENT_SPECIFIED, String.class); 72 | 73 | if (!request.getURI().getHost().contains("googlevideo")) { 74 | if (userAgent != null) { 75 | request.setHeader("User-Agent", userAgent); 76 | 77 | String visitorData = context.getAttribute(ATTRIBUTE_VISITOR_DATA_SPECIFIED, String.class); 78 | request.setHeader("X-Goog-Visitor-Id", visitorData != null ? visitorData : tokenTracker.getVisitorId()); 79 | 80 | context.removeAttribute(ATTRIBUTE_VISITOR_DATA_SPECIFIED); 81 | context.removeAttribute(ATTRIBUTE_USER_AGENT_SPECIFIED); 82 | } 83 | 84 | boolean isRequestFromOauthedClient = context.getAttribute(Client.OAUTH_CLIENT_ATTRIBUTE) == Boolean.TRUE; 85 | 86 | if (isRequestFromOauthedClient && Client.PLAYER_URL.equals(request.getURI().toString())) { 87 | // Look at the userdata for any provided oauth-token 88 | String oauthToken = context.getAttribute(OAUTH_INJECT_CONTEXT_ATTRIBUTE, String.class); 89 | // only apply the token to /player requests. 90 | if (oauthToken != null && !oauthToken.isEmpty()) { 91 | oauth2Handler.applyToken(request, oauthToken); 92 | } else { 93 | oauth2Handler.applyToken(request); 94 | } 95 | } 96 | } 97 | 98 | // try { 99 | // URI uri = new URIBuilder(request.getURI()) 100 | // .setParameter("key", YoutubeConstants.INNERTUBE_ANDROID_API_KEY) 101 | // .build(); 102 | // 103 | // if (request instanceof HttpRequestBase) { 104 | // ((HttpRequestBase) request).setURI(uri); 105 | // } else { 106 | // throw new IllegalStateException("Cannot update request URI."); 107 | // } 108 | // } catch (URISyntaxException e) { 109 | // throw new RuntimeException(e); 110 | // } 111 | } 112 | 113 | @Override 114 | public boolean onRequestResponse(HttpClientContext context, 115 | HttpUriRequest request, 116 | HttpResponse response) { 117 | if (response.getStatusLine().getStatusCode() == 429) { 118 | throw new FriendlyException("This IP address has been blocked by YouTube (429).", COMMON, null); 119 | } 120 | 121 | // if (tokenTracker.isTokenFetchContext(context) || retryCounter.getRetryCount(context) >= 1) { 122 | // return false; 123 | // } 124 | return false; 125 | } 126 | 127 | @Override 128 | public boolean onRequestException(HttpClientContext context, 129 | HttpUriRequest request, 130 | Throwable error) { 131 | // Always retry once in case of connection reset exception. 132 | if (HttpClientTools.isConnectionResetException(error)) { 133 | if (context.getAttribute(ATTRIBUTE_RESET_RETRY) == null) { 134 | context.setAttribute(ATTRIBUTE_RESET_RETRY, true); 135 | return true; 136 | } 137 | } 138 | 139 | return false; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/polyfill/DetailMessageBuilder.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.polyfill; 2 | 3 | import java.util.function.IntPredicate; 4 | 5 | public class DetailMessageBuilder { 6 | public final StringBuilder builder; 7 | 8 | public DetailMessageBuilder() { 9 | this.builder = new StringBuilder(); 10 | } 11 | 12 | public void appendHeader(String header) { 13 | builder.append(header); 14 | } 15 | 16 | public void appendField(String name, Object value) { 17 | builder.append("\n ").append(name).append(": "); 18 | 19 | if (value == null) { 20 | builder.append(""); 21 | } else { 22 | builder.append(value.toString()); 23 | } 24 | } 25 | 26 | public void appendField(String name, int value) { 27 | appendField(name, String.valueOf(value)); 28 | } 29 | 30 | public void appendArray(String label, boolean alwaysPrint, T[] array, IntPredicate check) { 31 | boolean started = false; 32 | 33 | for (int i = 0; i < array.length; i++) { 34 | if (check.test(i)) { 35 | if (!started) { 36 | builder.append("\n ").append(label).append(": "); 37 | started = true; 38 | } 39 | 40 | builder.append(array[i]).append(", "); 41 | } 42 | } 43 | 44 | if (started) { 45 | builder.setLength(builder.length() - 2); 46 | } else if (alwaysPrint) { 47 | appendField(label, "NONE"); 48 | } 49 | } 50 | 51 | @Override 52 | public String toString() { 53 | return builder.toString(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/track/TemporalInfo.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.track; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 4 | import com.sedmelluq.discord.lavaplayer.tools.Units; 5 | import org.jetbrains.annotations.NotNull; 6 | 7 | import static com.sedmelluq.discord.lavaplayer.tools.Units.DURATION_MS_UNKNOWN; 8 | 9 | public class TemporalInfo { 10 | public final boolean isActiveStream; 11 | public final long durationMillis; 12 | 13 | private TemporalInfo(boolean isActiveStream, long durationMillis) { 14 | this.isActiveStream = isActiveStream; 15 | this.durationMillis = durationMillis; 16 | } 17 | 18 | // normal video? but has liveStreamability: PRRBJOn_n-Y 19 | // livestream: jfKfPfyJRdk 20 | 21 | // active premieres have liveStreamability and videoDetails.isLive = true, videoDetails.isLiveContent = false. 22 | // they do retain their lengthSeconds value. 23 | 24 | @NotNull 25 | public static TemporalInfo fromRawData(JsonBrowser playabilityStatus, JsonBrowser videoDetails) { 26 | JsonBrowser durationField = videoDetails.get("lengthSeconds"); 27 | long durationValue = durationField.asLong(0L); 28 | 29 | // boolean hasLivestreamability = !playabilityStatus.get("liveStreamability").isNull(); 30 | boolean isLive = videoDetails.get("isLive").asBoolean(false); 31 | 32 | // fix: isLiveContent looks to only be for past livestreams and seems to yield false positives. 33 | // || videoDetails.get("isLiveContent").asBoolean(false); 34 | 35 | if (isLive) { // hasLivestreamability 36 | // Premieres have duration information, but act as a normal stream. When we play it, we don't know the 37 | // current position of it since YouTube doesn't provide such information, so assume duration is unknown. 38 | durationValue = 0; 39 | } 40 | 41 | return new TemporalInfo( 42 | isLive, 43 | durationValue == 0 ? DURATION_MS_UNKNOWN : Units.secondsToMillis(durationValue) 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/track/YoutubeAudioTrack.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.track; 2 | 3 | import com.sedmelluq.discord.lavaplayer.container.matroska.MatroskaAudioTrack; 4 | import com.sedmelluq.discord.lavaplayer.container.mpeg.MpegAudioTrack; 5 | import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager; 6 | import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools; 7 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; 8 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity; 9 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 10 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 11 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack; 12 | import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; 13 | import com.sedmelluq.discord.lavaplayer.track.DelegatedAudioTrack; 14 | import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor; 15 | import dev.lavalink.youtube.CannotBeLoaded; 16 | import dev.lavalink.youtube.ClientInformation; 17 | import dev.lavalink.youtube.UrlTools; 18 | import dev.lavalink.youtube.UrlTools.UrlInfo; 19 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 20 | import dev.lavalink.youtube.cipher.ScriptExtractionException; 21 | import dev.lavalink.youtube.clients.skeleton.Client; 22 | import dev.lavalink.youtube.track.format.StreamFormat; 23 | import dev.lavalink.youtube.track.format.TrackFormats; 24 | import org.jetbrains.annotations.NotNull; 25 | import org.jetbrains.annotations.Nullable; 26 | import org.slf4j.Logger; 27 | import org.slf4j.LoggerFactory; 28 | 29 | import java.io.IOException; 30 | import java.net.URI; 31 | import java.net.URISyntaxException; 32 | import java.util.Arrays; 33 | import java.util.Map; 34 | 35 | import static com.sedmelluq.discord.lavaplayer.container.Formats.MIME_AUDIO_WEBM; 36 | import static com.sedmelluq.discord.lavaplayer.tools.DataFormatTools.decodeUrlEncodedItems; 37 | import static com.sedmelluq.discord.lavaplayer.tools.Units.CONTENT_LENGTH_UNKNOWN; 38 | import static dev.lavalink.youtube.http.YoutubeOauth2Handler.OAUTH_INJECT_CONTEXT_ATTRIBUTE; 39 | 40 | /** 41 | * Audio track that handles processing Youtube videos as audio tracks. 42 | */ 43 | public class YoutubeAudioTrack extends DelegatedAudioTrack { 44 | private static final Logger log = LoggerFactory.getLogger(YoutubeAudioTrack.class); 45 | 46 | // This field is used to determine at what point should a stream be discarded. 47 | // If an error is thrown and the executor's position is larger than this number, 48 | // the stream URL will not be renewed. 49 | public static long BAD_STREAM_POSITION_THRESHOLD_MS = 3000; 50 | 51 | private final YoutubeAudioSourceManager sourceManager; 52 | 53 | /** 54 | * @param trackInfo Track info 55 | * @param sourceManager Source manager which was used to find this track 56 | */ 57 | public YoutubeAudioTrack(@NotNull AudioTrackInfo trackInfo, 58 | @NotNull YoutubeAudioSourceManager sourceManager) { 59 | super(trackInfo); 60 | this.sourceManager = sourceManager; 61 | } 62 | 63 | @Override 64 | public void process(LocalAudioTrackExecutor localExecutor) throws Exception { 65 | Client[] clients = sourceManager.getClients(); 66 | 67 | if (Arrays.stream(clients).noneMatch(Client::supportsFormatLoading)) { 68 | throw new FriendlyException("This video cannot be played", Severity.COMMON, 69 | new RuntimeException("None of the registered clients supports loading of formats")); 70 | } 71 | 72 | try (HttpInterface httpInterface = sourceManager.getInterface()) { 73 | try { 74 | Object userData = getUserData(); 75 | if (userData != null) { 76 | JsonBrowser jsonUserData = JsonBrowser.parse(userData.toString()); 77 | if (jsonUserData.get("oauth-token") != null) { 78 | httpInterface.getContext().setAttribute(OAUTH_INJECT_CONTEXT_ATTRIBUTE, jsonUserData.get("oauth-token").text()); 79 | } 80 | } 81 | } catch (IOException e) { 82 | log.debug("Failed to parse token from userData", e); 83 | } 84 | Exception lastException = null; 85 | 86 | for (Client client : clients) { 87 | if (!client.supportsFormatLoading()) { 88 | continue; 89 | } 90 | 91 | httpInterface.getContext().setAttribute(Client.OAUTH_CLIENT_ATTRIBUTE, client.supportsOAuth()); 92 | 93 | try { 94 | processWithClient(localExecutor, httpInterface, client, 0); 95 | return; // stream played through successfully, short-circuit. 96 | } catch (RuntimeException e) { 97 | // store exception so it can be thrown if we run out of clients to 98 | // load formats with. 99 | e.addSuppressed(ClientInformation.create(client)); 100 | lastException = e; 101 | 102 | if (e instanceof FriendlyException) { 103 | // usually thrown by getPlayabilityStatus when loading formats. 104 | // these aren't considered fatal, so we just store them and continue. 105 | continue; 106 | } 107 | 108 | if (e instanceof ScriptExtractionException) { 109 | // If we're still early in playback, we can try another client 110 | if (localExecutor.getPosition() <= BAD_STREAM_POSITION_THRESHOLD_MS) { 111 | continue; 112 | } 113 | } else if ("Not success status code: 403".equals(e.getMessage()) || 114 | "Invalid status code for player api response: 400".equals(e.getMessage())) { 115 | // As long as the executor position has not surpassed the threshold for which 116 | // a stream is considered unrecoverable, we can try to renew the playback URL with 117 | // another client. 118 | if (localExecutor.getPosition() <= BAD_STREAM_POSITION_THRESHOLD_MS) { 119 | continue; 120 | } 121 | } 122 | 123 | throw e; // Unhandled exception, just rethrow. 124 | } 125 | } 126 | 127 | if (lastException != null) { 128 | if (lastException instanceof FriendlyException) { 129 | if (!"YouTube WebM streams are currently not supported.".equals(lastException.getMessage())) { 130 | // Rethrow certain FriendlyExceptions as suspicious to ensure LavaPlayer logs them. 131 | throw new FriendlyException(lastException.getMessage(), Severity.SUSPICIOUS, lastException.getCause()); 132 | } 133 | 134 | throw lastException; 135 | } 136 | 137 | throw ExceptionTools.toRuntimeException(lastException); 138 | } 139 | } catch (CannotBeLoaded e) { 140 | throw ExceptionTools.wrapUnfriendlyExceptions("This video is unavailable", Severity.SUSPICIOUS, e.getCause()); 141 | } 142 | } 143 | 144 | private void processWithClient(LocalAudioTrackExecutor localExecutor, 145 | HttpInterface httpInterface, 146 | Client client, 147 | long streamPosition) throws CannotBeLoaded, Exception { 148 | FormatWithUrl augmentedFormat = loadBestFormatWithUrl(httpInterface, client); 149 | log.debug("Starting track with URL from client {}: {}", client.getIdentifier(), augmentedFormat.signedUrl); 150 | 151 | try { 152 | if (trackInfo.isStream || augmentedFormat.format.getContentLength() == CONTENT_LENGTH_UNKNOWN) { 153 | processStream(localExecutor, httpInterface, augmentedFormat); 154 | } else { 155 | processStatic(localExecutor, httpInterface, augmentedFormat, streamPosition); 156 | } 157 | } catch (StreamExpiredException e) { 158 | processWithClient(localExecutor, httpInterface, client, e.lastStreamPosition); 159 | } 160 | } 161 | 162 | private void processStatic(LocalAudioTrackExecutor localExecutor, 163 | HttpInterface httpInterface, 164 | FormatWithUrl augmentedFormat, 165 | long streamPosition) throws Exception { 166 | YoutubePersistentHttpStream stream = null; 167 | 168 | try { 169 | stream = new YoutubePersistentHttpStream(httpInterface, augmentedFormat.signedUrl, augmentedFormat.format.getContentLength()); 170 | 171 | if (streamPosition > 0) { 172 | stream.seek(streamPosition); 173 | } 174 | 175 | if (augmentedFormat.format.getType().getMimeType().endsWith("/webm")) { 176 | processDelegate(new MatroskaAudioTrack(trackInfo, stream), localExecutor); 177 | } else { 178 | processDelegate(new MpegAudioTrack(trackInfo, stream), localExecutor); 179 | } 180 | } catch (RuntimeException e) { 181 | if ("Not success status code: 403".equals(e.getMessage()) && augmentedFormat.isExpired() && stream != null) { 182 | throw new StreamExpiredException(stream.getPosition(), e); 183 | } 184 | 185 | throw e; 186 | } finally { 187 | if (stream != null) { 188 | stream.close(); 189 | } 190 | } 191 | } 192 | 193 | private void processStream(LocalAudioTrackExecutor localExecutor, 194 | HttpInterface httpInterface, 195 | FormatWithUrl augmentedFormat) throws Exception { 196 | if (MIME_AUDIO_WEBM.equals(augmentedFormat.format.getType().getMimeType())) { 197 | throw new FriendlyException("YouTube WebM streams are currently not supported.", Severity.COMMON, null); 198 | } 199 | 200 | // TODO: Catch 403 and retry? Can't use position though because it's a livestream. 201 | processDelegate(new YoutubeMpegStreamAudioTrack(trackInfo, httpInterface, augmentedFormat.signedUrl), localExecutor); 202 | } 203 | 204 | @NotNull 205 | private FormatWithUrl loadBestFormatWithUrl(@NotNull HttpInterface httpInterface, 206 | @NotNull Client client) throws CannotBeLoaded, Exception { 207 | if (!client.supportsFormatLoading()) { 208 | throw new RuntimeException(client.getIdentifier() + " does not support loading of formats!"); 209 | } 210 | 211 | TrackFormats formats = client.loadFormats(sourceManager, httpInterface, getIdentifier()); 212 | 213 | if (formats == null) { 214 | throw new FriendlyException("This video cannot be played", Severity.SUSPICIOUS, null); 215 | } 216 | 217 | StreamFormat format = formats.getBestFormat(); 218 | 219 | URI resolvedUrl = format.getUrl(); 220 | if (client.requirePlayerScript()) { 221 | resolvedUrl = sourceManager.getCipherManager() 222 | .resolveFormatUrl(httpInterface, formats.getPlayerScriptUrl(), format); 223 | resolvedUrl = client.transformPlaybackUri(format.getUrl(), resolvedUrl); 224 | } 225 | 226 | return new FormatWithUrl(format, resolvedUrl); 227 | } 228 | 229 | @Override 230 | protected AudioTrack makeShallowClone() { 231 | return new YoutubeAudioTrack(trackInfo, sourceManager); 232 | } 233 | 234 | @Override 235 | public AudioSourceManager getSourceManager() { 236 | return sourceManager; 237 | } 238 | 239 | @Override 240 | public boolean isSeekable() { 241 | return true; 242 | } 243 | 244 | private static class FormatWithUrl { 245 | private final StreamFormat format; 246 | private final URI signedUrl; 247 | 248 | private FormatWithUrl(@NotNull StreamFormat format, 249 | @NotNull URI signedUrl) { 250 | this.format = format; 251 | this.signedUrl = signedUrl; 252 | } 253 | 254 | public boolean isExpired() { 255 | UrlInfo urlInfo = UrlTools.getUrlInfo(signedUrl.toString(), true); 256 | String expire = urlInfo.parameters.get("expire"); 257 | 258 | if (expire == null) { 259 | return false; 260 | } 261 | 262 | long expiresAbsMillis = Long.parseLong(expire) * 1000; 263 | return System.currentTimeMillis() >= expiresAbsMillis; 264 | } 265 | 266 | @Nullable 267 | public FormatWithUrl getFallback() { 268 | String signedString = signedUrl.toString(); 269 | Map urlParameters = decodeUrlEncodedItems(signedString, false); 270 | 271 | String mn = urlParameters.get("mn"); 272 | 273 | if (mn == null) { 274 | return null; 275 | } 276 | 277 | String[] hosts = mn.split(","); 278 | 279 | if (hosts.length < 2) { 280 | log.warn("Cannot fallback, available hosts: {}", String.join(", ", hosts)); 281 | return null; 282 | } 283 | 284 | String newUrl = signedString.replaceFirst(hosts[0], hosts[1]); 285 | 286 | try { 287 | URI uri = new URI(newUrl); 288 | return new FormatWithUrl(format, uri); 289 | } catch (URISyntaxException e) { 290 | return null; 291 | } 292 | } 293 | } 294 | 295 | private static class StreamExpiredException extends RuntimeException { 296 | private final long lastStreamPosition; 297 | 298 | private StreamExpiredException(long lastStreamPosition, 299 | @NotNull Throwable cause) { 300 | super(null, cause, true, false); 301 | this.lastStreamPosition = lastStreamPosition; 302 | } 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/track/YoutubeMpegStreamAudioTrack.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.track; 2 | 3 | import com.sedmelluq.discord.lavaplayer.container.mpeg.MpegAudioTrack; 4 | import com.sedmelluq.discord.lavaplayer.container.mpeg.MpegFileLoader; 5 | import com.sedmelluq.discord.lavaplayer.container.mpeg.MpegTrackConsumer; 6 | import com.sedmelluq.discord.lavaplayer.container.mpeg.reader.MpegFileTrackProvider; 7 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 8 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; 9 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 10 | import com.sedmelluq.discord.lavaplayer.tools.io.SeekableInputStream; 11 | import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; 12 | import com.sedmelluq.discord.lavaplayer.track.playback.AudioProcessingContext; 13 | import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor; 14 | import org.apache.http.HttpStatus; 15 | import org.apache.http.client.config.RequestConfig; 16 | import org.apache.http.client.utils.URIBuilder; 17 | import org.slf4j.Logger; 18 | import org.slf4j.LoggerFactory; 19 | 20 | import java.io.IOException; 21 | import java.net.URI; 22 | import java.net.URISyntaxException; 23 | import java.nio.charset.StandardCharsets; 24 | import java.util.concurrent.TimeUnit; 25 | 26 | import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS; 27 | import static com.sedmelluq.discord.lavaplayer.tools.Units.CONTENT_LENGTH_UNKNOWN; 28 | 29 | /** 30 | * YouTube segmented MPEG stream track. The base URL always gives the latest chunk. Every chunk contains the current 31 | * sequence number in it, which is used to get the sequence number of the next segment. This is repeated until YouTube 32 | * responds to a segment request with 204. 33 | */ 34 | public class YoutubeMpegStreamAudioTrack extends MpegAudioTrack { 35 | private static final Logger log = LoggerFactory.getLogger(YoutubeMpegStreamAudioTrack.class); 36 | private static final RequestConfig streamingRequestConfig = RequestConfig.custom() 37 | .setSocketTimeout(3000) 38 | .setConnectionRequestTimeout(3000) 39 | .setConnectTimeout(3000) 40 | .build(); 41 | private static final long EMPTY_RETRY_THRESHOLD_MS = 400; 42 | private static final long EMPTY_RETRY_INTERVAL_MS = 50; 43 | private static final long MAX_REWIND_TIME = 43200; // Seconds 44 | 45 | private final HttpInterface httpInterface; 46 | private final TrackState state; 47 | 48 | /** 49 | * @param trackInfo Track info 50 | * @param httpInterface HTTP interface to use for loading segments 51 | * @param signedUrl URI of the base stream with signature resolved 52 | */ 53 | public YoutubeMpegStreamAudioTrack(AudioTrackInfo trackInfo, 54 | HttpInterface httpInterface, 55 | URI signedUrl) { 56 | super(trackInfo, null); 57 | 58 | this.httpInterface = httpInterface; 59 | this.state = new TrackState(signedUrl); 60 | 61 | // YouTube does not return a segment until it is ready, this might trigger a connect timeout otherwise. 62 | httpInterface.getContext().setRequestConfig(streamingRequestConfig); 63 | updateGlobalSequence(); 64 | } 65 | 66 | @Override 67 | public void process(LocalAudioTrackExecutor localExecutor) { 68 | localExecutor.executeProcessingLoop(() -> execute(localExecutor), this::seek); 69 | } 70 | 71 | @Override 72 | public void setPosition(long position) { 73 | state.seeking = true; 74 | updateGlobalSequence(); 75 | getActiveExecutor().setPosition(position); 76 | } 77 | 78 | @Override 79 | public long getDuration() { 80 | return TimeUnit.SECONDS.toMillis(state.globalSequence * TimeUnit.MILLISECONDS.toSeconds(state.globalSequenceDuration)); 81 | } 82 | 83 | @Override 84 | public long getPosition() { 85 | if (state.absoluteSequence == null) { 86 | return super.getPosition(); 87 | } 88 | 89 | return TimeUnit.SECONDS.toMillis(state.absoluteSequence * TimeUnit.MILLISECONDS.toSeconds(state.globalSequenceDuration)); 90 | } 91 | 92 | private void updateGlobalSequence() { 93 | try (YoutubePersistentHttpStream stream = new YoutubePersistentHttpStream(httpInterface, state.initialUrl, CONTENT_LENGTH_UNKNOWN)) { 94 | MpegFileLoader file = new MpegFileLoader(stream); 95 | file.parseHeaders(); 96 | 97 | SequenceInfo sequenceInfo = extractAbsoluteSequenceFromEvent(file.getLastEventMessage()); 98 | 99 | if (sequenceInfo != null) { 100 | state.globalSequence = sequenceInfo.sequence; 101 | state.globalSequenceDuration = sequenceInfo.duration; 102 | } 103 | } catch (IOException ignored) { 104 | 105 | } 106 | } 107 | 108 | private void execute(LocalAudioTrackExecutor localExecutor) throws InterruptedException { 109 | if (!trackInfo.isStream && state.absoluteSequence == null) { 110 | state.absoluteSequence = 0L; 111 | } 112 | 113 | try { 114 | while (!state.finished) { 115 | processNextSegmentWithRetry(localExecutor); 116 | state.relativeSequence++; 117 | state.globalSequence++; 118 | } 119 | } finally { 120 | if (state.trackConsumer != null && !state.seeking) { 121 | state.trackConsumer.close(); 122 | } else { 123 | state.seeking = false; 124 | } 125 | } 126 | } 127 | 128 | private void seek(long timecode) { 129 | long seconds = TimeUnit.MILLISECONDS.toSeconds(timecode); 130 | 131 | if (seconds > state.globalSequence) { 132 | seconds = state.globalSequence; 133 | } else if (state.globalSequence - seconds > MAX_REWIND_TIME) { 134 | seconds = state.globalSequence - MAX_REWIND_TIME; 135 | } 136 | 137 | state.absoluteSequence = seconds - 1; 138 | } 139 | 140 | private void processNextSegmentWithRetry( 141 | LocalAudioTrackExecutor localExecutor 142 | ) throws InterruptedException { 143 | if (processNextSegment(localExecutor)) { 144 | return; 145 | } 146 | 147 | // First attempt gave empty result, possibly because the stream is not yet finished, but the next segment is just 148 | // not ready yet. Keep retrying at EMPTY_RETRY_INTERVAL_MS intervals until EMPTY_RETRY_THRESHOLD_MS is reached. 149 | long waitStart = System.currentTimeMillis(); 150 | long iterationStart = waitStart; 151 | 152 | while (!processNextSegment(localExecutor)) { 153 | // EMPTY_RETRY_THRESHOLD_MS is the maximum time between the end of the first attempt and the beginning of the last 154 | // attempt, to avoid retry being skipped due to response coming slowly. 155 | if (iterationStart - waitStart >= EMPTY_RETRY_THRESHOLD_MS) { 156 | state.finished = true; 157 | break; 158 | } else { 159 | Thread.sleep(EMPTY_RETRY_INTERVAL_MS); 160 | iterationStart = System.currentTimeMillis(); 161 | } 162 | } 163 | } 164 | 165 | private boolean processNextSegment( 166 | LocalAudioTrackExecutor localExecutor 167 | ) throws InterruptedException { 168 | URI segmentUrl = getNextSegmentUrl(state); 169 | 170 | log.debug("Segment URL: {}", segmentUrl.toString()); 171 | 172 | try (YoutubePersistentHttpStream stream = new YoutubePersistentHttpStream(httpInterface, segmentUrl, CONTENT_LENGTH_UNKNOWN)) { 173 | if (stream.checkStatusCode() == HttpStatus.SC_NO_CONTENT || stream.getContentLength() == 0) { 174 | return false; 175 | } 176 | 177 | // If we were redirected, use that URL as a base for the next segment URL. Otherwise we will likely get redirected 178 | // again on every other request, which is inefficient (redirects across domains, the original URL is always 179 | // closing the connection, whereas the final URL is keep-alive). 180 | state.redirectUrl = httpInterface.getFinalLocation(); 181 | 182 | processSegmentStream(stream, localExecutor.getProcessingContext(), state); 183 | 184 | stream.releaseConnection(); 185 | } catch (IOException e) { 186 | // IOException here usually means that stream is about to end. 187 | return false; 188 | } 189 | 190 | return true; 191 | } 192 | 193 | private void processSegmentStream(SeekableInputStream stream, AudioProcessingContext context, TrackState state) throws InterruptedException, IOException { 194 | MpegFileLoader file = new MpegFileLoader(stream); 195 | file.parseHeaders(); 196 | 197 | if (!trackInfo.isStream) { 198 | state.absoluteSequence++; 199 | } else { 200 | SequenceInfo sequenceInfo = extractAbsoluteSequenceFromEvent(file.getLastEventMessage()); 201 | 202 | if (sequenceInfo != null) { 203 | state.absoluteSequence = sequenceInfo.sequence; 204 | } 205 | } 206 | 207 | if (state.trackConsumer == null) { 208 | state.trackConsumer = loadAudioTrack(file, context); 209 | } 210 | 211 | MpegFileTrackProvider fileReader = file.loadReader(state.trackConsumer); 212 | if (fileReader == null) { 213 | throw new FriendlyException("Unknown MP4 format.", SUSPICIOUS, null); 214 | } 215 | 216 | fileReader.provideFrames(); 217 | } 218 | 219 | private URI getNextSegmentUrl(TrackState state) { 220 | URIBuilder builder = new URIBuilder(state.redirectUrl == null ? state.initialUrl : state.redirectUrl) 221 | .setParameter("rn", String.valueOf(state.relativeSequence)) 222 | .setParameter("rbuf", "0"); 223 | 224 | if (state.absoluteSequence != null) { 225 | builder.setParameter("sq", String.valueOf(state.absoluteSequence + 1)); 226 | } 227 | 228 | try { 229 | return builder.build(); 230 | } catch (URISyntaxException e) { 231 | throw new RuntimeException(e); 232 | } 233 | } 234 | 235 | private SequenceInfo extractAbsoluteSequenceFromEvent(byte[] data) { 236 | if (data == null) { 237 | return null; 238 | } 239 | 240 | String message = new String(data, StandardCharsets.UTF_8); 241 | String sequence = DataFormatTools.extractBetween(message, "Sequence-Number: ", "\r\n"); 242 | String duration = DataFormatTools.extractBetween(message, "Target-Duration-Us: ", "\r\n"); 243 | 244 | if (sequence != null && duration != null) { 245 | return new SequenceInfo(Long.parseLong(sequence), TimeUnit.MICROSECONDS.toMillis(Long.parseLong(duration))); 246 | } 247 | 248 | return null; 249 | } 250 | 251 | private static class TrackState { 252 | private long globalSequenceDuration; 253 | private long globalSequence; 254 | private long relativeSequence; 255 | private Long absoluteSequence; 256 | private MpegTrackConsumer trackConsumer; 257 | private boolean finished; 258 | private boolean seeking; 259 | private URI redirectUrl; 260 | private final URI initialUrl; 261 | 262 | public TrackState(URI initialUrl) { 263 | this.initialUrl = initialUrl; 264 | } 265 | } 266 | 267 | private static class SequenceInfo { 268 | private final long sequence; 269 | private final long duration; 270 | 271 | public SequenceInfo(long sequence, long duration) { 272 | this.sequence = sequence; 273 | this.duration = duration; 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/track/YoutubePersistentHttpStream.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.track; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 5 | import com.sedmelluq.discord.lavaplayer.tools.io.PersistentHttpStream; 6 | import org.apache.http.client.utils.URIBuilder; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | 10 | import java.io.IOException; 11 | import java.net.URI; 12 | import java.net.URISyntaxException; 13 | 14 | /** 15 | * A persistent HTTP stream implementation that uses the range parameter instead of HTTP headers for specifying 16 | * the start position at which to start reading on a new connection. 17 | */ 18 | public class YoutubePersistentHttpStream extends PersistentHttpStream { 19 | private static final Logger log = LoggerFactory.getLogger(YoutubePersistentHttpStream.class); 20 | 21 | // Valid range for requesting without throttling is 0-11862014 22 | private static final long BUFFER_SIZE = 11862014; 23 | 24 | private long rangeEnd; 25 | 26 | /** 27 | * @param httpInterface The HTTP interface to use for requests 28 | * @param contentUrl The URL of the resource 29 | * @param contentLength The length of the resource in bytes 30 | */ 31 | public YoutubePersistentHttpStream(HttpInterface httpInterface, URI contentUrl, long contentLength) { 32 | super(httpInterface, contentUrl, contentLength); 33 | } 34 | 35 | @Override 36 | protected URI getConnectUrl() { 37 | if (!contentUrl.toString().contains("rn=")) { 38 | URI rangeUrl = getNextRangeUrl(); 39 | 40 | log.debug("Range URL: {}", rangeUrl.toString()); 41 | 42 | return rangeUrl; 43 | } else { 44 | return contentUrl; 45 | } 46 | } 47 | 48 | @Override 49 | protected int internalRead(byte[] b, int off, int len, boolean attemptReconnect) throws IOException { 50 | connect(false); 51 | long nextExpectedPosition = position + len + (len / 2); 52 | 53 | try { 54 | int result; 55 | if (nextExpectedPosition >= rangeEnd && rangeEnd != 0) { 56 | if (rangeEnd == contentLength) { 57 | result = currentContent.read(b, off, len); 58 | position += result; 59 | } else { 60 | result = 0; 61 | handleRangeEnd(null, attemptReconnect); 62 | } 63 | } else { 64 | result = currentContent.read(b, off, len); 65 | if (result >= 0) { 66 | position += result; 67 | if (position >= rangeEnd && !contentUrl.toString().contains("rn=")) { 68 | handleRangeEnd(null, attemptReconnect); 69 | } 70 | } 71 | } 72 | 73 | return result; 74 | } catch (IOException e) { 75 | handleRangeEnd(e, attemptReconnect); 76 | return internalRead(b, off, len, false); 77 | } 78 | } 79 | 80 | @Override 81 | protected long internalSkip(long n, boolean attemptReconnect) throws IOException { 82 | connect(false); 83 | long nextExpectedPosition = position + n; 84 | 85 | try { 86 | long result; 87 | if (nextExpectedPosition >= rangeEnd && rangeEnd != 0) { 88 | if (rangeEnd == contentLength) { 89 | result = currentContent.skip(n); 90 | position += result; 91 | } else { 92 | result = n; 93 | position += n; 94 | handleRangeEnd(null, attemptReconnect); 95 | } 96 | } else { 97 | result = currentContent.skip(n); 98 | position += result; 99 | if (position >= rangeEnd && !contentUrl.toString().contains("rn=")) { 100 | handleRangeEnd(null, attemptReconnect); 101 | } 102 | } 103 | 104 | return result; 105 | } catch (IOException e) { 106 | handleRangeEnd(e, attemptReconnect); 107 | return internalSkip(n, false); 108 | } 109 | } 110 | 111 | private URI getNextRangeUrl() { 112 | rangeEnd = position + BUFFER_SIZE; 113 | 114 | if (rangeEnd > contentLength) { 115 | rangeEnd = contentLength; 116 | } 117 | 118 | try { 119 | return new URIBuilder(contentUrl).addParameter("range", position + "-" + rangeEnd).build(); 120 | } catch (URISyntaxException e) { 121 | throw new RuntimeException(e); 122 | } 123 | } 124 | 125 | private void handleRangeEnd(IOException exception, boolean attemptReconnect) throws IOException { 126 | if (!attemptReconnect || (!HttpClientTools.isRetriableNetworkException(exception) && exception != null)) { 127 | throw exception; 128 | } 129 | 130 | close(); 131 | } 132 | 133 | @Override 134 | protected boolean useHeadersForRange() { 135 | return false; 136 | } 137 | 138 | @Override 139 | public boolean canSeekHard() { 140 | return true; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/track/format/FormatInfo.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.track.format; 2 | 3 | import org.apache.http.entity.ContentType; 4 | import org.jetbrains.annotations.NotNull; 5 | import org.jetbrains.annotations.Nullable; 6 | 7 | import static com.sedmelluq.discord.lavaplayer.container.Formats.*; 8 | 9 | /** 10 | * The mime type and codec info of a YouTube track format. 11 | */ 12 | public enum FormatInfo { 13 | WEBM_OPUS(MIME_AUDIO_WEBM, CODEC_OPUS), 14 | WEBM_VORBIS(MIME_AUDIO_WEBM, CODEC_VORBIS), 15 | MP4_AAC_LC(MIME_AUDIO_MP4, CODEC_AAC_LC), 16 | WEBM_VIDEO_VORBIS(MIME_VIDEO_WEBM, CODEC_VORBIS), 17 | MP4_VIDEO_AAC_LC(MIME_VIDEO_MP4, CODEC_AAC_LC); 18 | 19 | /** 20 | * Mime type of the format 21 | */ 22 | public final String mimeType; 23 | /** 24 | * Codec name of the format 25 | */ 26 | public final String codec; 27 | 28 | FormatInfo(String mimeType, String codec) { 29 | this.mimeType = mimeType; 30 | this.codec = codec; 31 | } 32 | 33 | /** 34 | * Find a matching format info instance from a content type. 35 | * @param contentType The content type to use for matching against known formats 36 | * @return The format info entry that matches the content type 37 | */ 38 | @Nullable 39 | public static FormatInfo get(@NotNull ContentType contentType) { 40 | String mimeType = contentType.getMimeType(); 41 | String codec = contentType.getParameter("codecs"); 42 | 43 | // Check accurate matches 44 | for (FormatInfo formatInfo : FormatInfo.values()) { 45 | if (formatInfo.mimeType.equals(mimeType) && formatInfo.codec.equals(codec)) { 46 | return formatInfo; 47 | } 48 | } 49 | 50 | // Check for substring matches 51 | for (FormatInfo formatInfo : FormatInfo.values()) { 52 | if (formatInfo.mimeType.equals(mimeType) && codec.contains(formatInfo.codec)) { 53 | return formatInfo; 54 | } 55 | } 56 | 57 | return null; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/track/format/StreamFormat.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.track.format; 2 | 3 | import org.apache.http.entity.ContentType; 4 | import org.jetbrains.annotations.NotNull; 5 | import org.jetbrains.annotations.Nullable; 6 | 7 | import java.net.URI; 8 | import java.net.URISyntaxException; 9 | 10 | /** 11 | * Describes an available media format for a track 12 | */ 13 | public class StreamFormat { 14 | private final FormatInfo info; 15 | private final ContentType type; 16 | private final int itag; 17 | private final long bitrate; 18 | private final long contentLength; 19 | private final long audioChannels; 20 | private final String url; 21 | private final String nParameter; 22 | private final String signature; 23 | private final String signatureKey; 24 | private final boolean defaultAudioTrack; 25 | private final boolean isDrc; 26 | 27 | /** 28 | * @param type Mime type of the format 29 | * @param bitrate Bitrate of the format 30 | * @param contentLength Length in bytes of the media 31 | * @param audioChannels Number of audio channels 32 | * @param url Base URL for the playback of this format 33 | * @param nParameter n parameter for this format 34 | * @param signature Cipher signature for this format 35 | * @param signatureKey The key to use for deciphered signature in the final playback URL 36 | * @param isDefaultAudioTrack Whether this format contains an audio track that is used by default. 37 | * @param isDrc Whether this format has Dynamic Range Compression. 38 | */ 39 | public StreamFormat( 40 | ContentType type, 41 | int itag, 42 | long bitrate, 43 | long contentLength, 44 | long audioChannels, 45 | String url, 46 | String nParameter, 47 | String signature, 48 | String signatureKey, 49 | boolean isDefaultAudioTrack, 50 | boolean isDrc 51 | ) { 52 | this.info = FormatInfo.get(type); 53 | this.type = type; 54 | this.itag = itag; 55 | this.bitrate = bitrate; 56 | this.contentLength = contentLength; 57 | this.audioChannels = audioChannels; 58 | this.url = url; 59 | this.nParameter = nParameter; 60 | this.signature = signature; 61 | this.signatureKey = signatureKey; 62 | this.defaultAudioTrack = isDefaultAudioTrack; 63 | this.isDrc = isDrc; 64 | } 65 | 66 | /** 67 | * @return Format container and codec info 68 | */ 69 | @Nullable 70 | public FormatInfo getInfo() { 71 | return info; 72 | } 73 | 74 | /** 75 | * @return Mime type of the format 76 | */ 77 | @NotNull 78 | public ContentType getType() { 79 | return type; 80 | } 81 | 82 | public int getItag() { 83 | return itag; 84 | } 85 | 86 | /** 87 | * @return Bitrate of the format 88 | */ 89 | public long getBitrate() { 90 | return bitrate; 91 | } 92 | 93 | /** 94 | * @return Count of audio channels in format 95 | */ 96 | public long getAudioChannels() { 97 | return audioChannels; 98 | } 99 | 100 | /** 101 | * @return Base URL for the playback of this format 102 | */ 103 | @NotNull 104 | public URI getUrl() { 105 | try { 106 | return new URI(url); 107 | } catch (URISyntaxException e) { 108 | throw new RuntimeException(e); 109 | } 110 | } 111 | 112 | /** 113 | * @return Length in bytes of the media 114 | */ 115 | public long getContentLength() { 116 | return contentLength; 117 | } 118 | 119 | /** 120 | * @return n parameter for this format 121 | */ 122 | @Nullable 123 | public String getNParameter() { 124 | return nParameter; 125 | } 126 | 127 | /** 128 | * @return Cipher signature for this format 129 | */ 130 | @Nullable 131 | public String getSignature() { 132 | return signature; 133 | } 134 | 135 | /** 136 | * @return The key to use for deciphered signature in the final playback URL 137 | */ 138 | @Nullable 139 | public String getSignatureKey() { 140 | return signatureKey; 141 | } 142 | 143 | /** 144 | * @return Whether this format contains an audio track that is used by default. 145 | */ 146 | public boolean isDefaultAudioTrack() { 147 | return defaultAudioTrack; 148 | } 149 | 150 | /** 151 | * @return Whether this format has Dynamic Range Compression. 152 | */ 153 | public boolean isDrc() { 154 | return isDrc; 155 | } 156 | 157 | @Override 158 | public String toString() { 159 | return "YoutubeStreamFormat{" + 160 | "itag=" + itag + 161 | ", type=" + type + 162 | ", bitrate=" + bitrate + 163 | ", audioChannels=" + audioChannels + 164 | ", isDrc=" + isDrc + 165 | '}'; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /common/src/main/java/dev/lavalink/youtube/track/format/TrackFormats.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.track.format; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.util.List; 6 | import java.util.StringJoiner; 7 | 8 | import static com.sedmelluq.discord.lavaplayer.container.Formats.MIME_AUDIO_WEBM; 9 | 10 | public class TrackFormats { 11 | private final List formats; 12 | private final String playerScriptUrl; 13 | 14 | public TrackFormats(@NotNull List formats, 15 | @NotNull String playerScriptUrl) { 16 | this.formats = formats; 17 | this.playerScriptUrl = playerScriptUrl; 18 | } 19 | 20 | @NotNull 21 | public List getFormats() { 22 | return this.formats; 23 | } 24 | 25 | @NotNull 26 | public String getPlayerScriptUrl() { 27 | return playerScriptUrl; 28 | } 29 | 30 | @NotNull 31 | public StreamFormat getBestFormat() { 32 | StreamFormat bestFormat = null; 33 | 34 | for (StreamFormat format : formats) { 35 | if (!format.isDefaultAudioTrack()) { 36 | continue; 37 | } 38 | 39 | if (isBetterFormat(format, bestFormat)) { 40 | bestFormat = format; 41 | } 42 | } 43 | 44 | if (bestFormat == null) { 45 | StringJoiner joiner = new StringJoiner(", "); 46 | formats.forEach(format -> joiner.add(format.getType().toString())); 47 | throw new RuntimeException("No supported audio streams available, available types: " + joiner); 48 | } 49 | 50 | return bestFormat; 51 | } 52 | 53 | private static boolean isBetterFormat(StreamFormat format, StreamFormat other) { 54 | FormatInfo info = format.getInfo(); 55 | 56 | if (info == null) { 57 | return false; 58 | } else if (other == null) { 59 | return true; 60 | } else if (MIME_AUDIO_WEBM.equals(info.mimeType) && format.getAudioChannels() > 2) { 61 | // Opus with more than 2 audio channels is unsupported by LavaPlayer currently. 62 | return false; 63 | } else if (info.ordinal() != other.getInfo().ordinal()) { 64 | return info.ordinal() < other.getInfo().ordinal(); 65 | } else if (format.isDrc() && !other.isDrc()) { 66 | // prefer non-drc formats 67 | // IF ANYTHING BREAKS/SOUNDS BAD, REMOVE THIS 68 | return false; 69 | } else { 70 | return format.getBitrate() > other.getBitrate(); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /common/src/main/resources/yts-version.txt: -------------------------------------------------------------------------------- 1 | @version@ -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lavalink-devs/youtube-source/a484d0a49f126cf3ed1954047f75ac31f210e9b3/gradle.properties -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lavalink-devs/youtube-source/a484d0a49f126cf3ed1954047f75ac31f210e9b3/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk17 3 | install: 4 | - ./gradlew clean build publishToMavenLocal 5 | -------------------------------------------------------------------------------- /plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.JavaLibrary 2 | import com.vanniktech.maven.publish.JavadocJar 3 | import org.apache.tools.ant.filters.ReplaceTokens 4 | 5 | plugins { 6 | `java-library` 7 | alias(libs.plugins.lavalink.gradle.plugin) 8 | alias(libs.plugins.maven.publish.base) 9 | } 10 | 11 | lavalinkPlugin { 12 | name = "youtube-plugin" 13 | path = "dev.lavalink.youtube.plugin" 14 | apiVersion = libs.versions.lavalink 15 | serverVersion = "4.0.7" 16 | configurePublishing = false 17 | } 18 | 19 | base { 20 | archivesName = "youtube-plugin" 21 | } 22 | 23 | dependencies { 24 | implementation(projects.common) 25 | implementation(projects.v2) 26 | compileOnly(libs.lavalink.server) 27 | compileOnly(libs.lavaplayer.ext.youtube.rotator) 28 | implementation(libs.rhino.engine) 29 | implementation(libs.nanojson) 30 | compileOnly(libs.slf4j) 31 | compileOnly(libs.annotations) 32 | } 33 | 34 | java { 35 | sourceCompatibility = JavaVersion.VERSION_11 36 | targetCompatibility = JavaVersion.VERSION_11 37 | } 38 | 39 | mavenPublishing { 40 | coordinates("dev.lavalink.youtube", "youtube-plugin", version.toString()) 41 | configure(JavaLibrary(JavadocJar.None(), sourcesJar = false)) 42 | } 43 | 44 | tasks.jar { 45 | dependsOn(":common:compileTestJava") 46 | } 47 | 48 | tasks { 49 | processResources { 50 | filter( 51 | "tokens" to mapOf( 52 | "version" to project.version 53 | ) 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/ClientProvider.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | import dev.lavalink.youtube.clients.ClientOptions; 4 | import dev.lavalink.youtube.clients.skeleton.Client; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | 8 | import java.util.ArrayList; 9 | import java.util.Arrays; 10 | import java.util.List; 11 | 12 | public interface ClientProvider { 13 | Logger log = LoggerFactory.getLogger(ClientProvider.class); 14 | 15 | default String[] getDefaultClients() { 16 | // This is a default list of clients. This list matches that of the 17 | // YoutubeAudioSourceManager. If that is updated, this should probably be 18 | // updated too. 19 | return new String[] { "MUSIC", "WEB", "ANDROID_TESTSUITE", "TVHTML5EMBEDDED" }; 20 | } 21 | 22 | Client[] getClients(String[] clients, OptionsProvider optionsProvider); 23 | 24 | default Client[] getClients(ClientReference[] clientValues, 25 | String[] clients, 26 | OptionsProvider optionsProvider) { 27 | List resolved = new ArrayList<>(); 28 | 29 | for (String clientName : clients) { 30 | Client client = getClientByName(clientValues, clientName, optionsProvider); 31 | 32 | if (client == null) { 33 | log.warn("Failed to resolve {} into a Client", clientName); 34 | continue; 35 | } 36 | 37 | resolved.add(client); 38 | } 39 | 40 | return resolved.toArray(new Client[0]); 41 | } 42 | 43 | static Client getClientByName(ClientReference[] enumValues, 44 | String name, 45 | OptionsProvider provider) { 46 | return Arrays.stream(enumValues) 47 | .filter(it -> it.getName().equals(name)) 48 | .findFirst() 49 | .map(ref -> { 50 | ClientOptions options = provider.getOptionsForClient(name); 51 | 52 | log.debug("Initialising client {} with options {}", ref.getName(), options); 53 | return ref.getClient(options); 54 | }) 55 | .orElse(null); 56 | } 57 | 58 | interface ClientReference { 59 | String getName(); 60 | Client getClient(ClientOptions clientOptions); 61 | } 62 | 63 | interface OptionsProvider { 64 | ClientOptions getOptionsForClient(String clientName); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/ClientProviderV3.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | import dev.lavalink.youtube.clients.*; 4 | import dev.lavalink.youtube.clients.skeleton.Client; 5 | 6 | public class ClientProviderV3 implements ClientProvider { 7 | @Override 8 | public Client[] getClients(String[] clients, OptionsProvider optionsProvider) { 9 | return getClients(ClientMapping.values(), clients, optionsProvider); 10 | } 11 | 12 | private enum ClientMapping implements ClientReference { 13 | ANDROID(Android::new), 14 | ANDROID_MUSIC(AndroidMusic::new), 15 | ANDROID_VR(AndroidVr::new), 16 | IOS(Ios::new), 17 | MUSIC(Music::new), 18 | TV(Tv::new), 19 | TVHTML5EMBEDDED(TvHtml5Embedded::new), 20 | WEB(Web::new), 21 | WEBEMBEDDED(WebEmbedded::new), 22 | MWEB(MWeb::new); 23 | 24 | private final ClientWithOptions clientFactory; 25 | 26 | ClientMapping(ClientWithOptions clientFactory) { 27 | this.clientFactory = clientFactory; 28 | } 29 | 30 | @Override 31 | public String getName() { 32 | return name(); 33 | } 34 | 35 | @Override 36 | public Client getClient(ClientOptions options) { 37 | return clientFactory.create(options); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/ClientProviderV4.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | import dev.lavalink.youtube.clients.*; 4 | import dev.lavalink.youtube.clients.skeleton.Client; 5 | 6 | public class ClientProviderV4 implements ClientProvider { 7 | @Override 8 | public Client[] getClients(String[] clients, OptionsProvider optionsProvider) { 9 | return getClients(ClientMapping.values(), clients, optionsProvider); 10 | } 11 | 12 | private enum ClientMapping implements ClientReference { 13 | ANDROID(AndroidWithThumbnail::new), 14 | ANDROID_MUSIC(AndroidMusicWithThumbnail::new), 15 | ANDROID_VR(AndroidVrWithThumbnail::new), 16 | IOS(IosWithThumbnail::new), 17 | MUSIC(MusicWithThumbnail::new), 18 | TV(Tv::new), // This has no WithThumbnail companion as it's a playback-only client. 19 | TVHTML5EMBEDDED(TvHtml5EmbeddedWithThumbnail::new), 20 | WEB(WebWithThumbnail::new), 21 | WEBEMBEDDED(WebEmbeddedWithThumbnail::new), 22 | MWEB(MWebWithThumbnail::new); 23 | 24 | private final ClientWithOptions clientFactory; 25 | 26 | ClientMapping(ClientWithOptions clientFactory) { 27 | this.clientFactory = clientFactory; 28 | } 29 | 30 | @Override 31 | public String getName() { 32 | return name(); 33 | } 34 | 35 | @Override 36 | public Client getClient(ClientOptions options) { 37 | return clientFactory.create(options); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/IOUtils.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | import java.io.Closeable; 4 | 5 | public class IOUtils { 6 | public static void closeQuietly(Closeable... closeables) { 7 | for (Closeable closeable : closeables) { 8 | try { 9 | closeable.close(); 10 | } catch (Throwable ignored) { 11 | 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/PluginInfo.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | import com.grack.nanojson.JsonArray; 4 | import com.grack.nanojson.JsonObject; 5 | import com.grack.nanojson.JsonParser; 6 | import com.grack.nanojson.JsonParserException; 7 | import dev.lavalink.youtube.YoutubeSource; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | 11 | import java.io.IOException; 12 | import java.io.InputStream; 13 | import java.lang.module.ModuleDescriptor.Version; 14 | import java.net.URL; 15 | import java.util.Properties; 16 | 17 | public class PluginInfo { 18 | private static final Logger log = LoggerFactory.getLogger(PluginInfo.class); 19 | 20 | public static void checkForNewRelease() throws IOException, JsonParserException { 21 | String versionS = YoutubeSource.VERSION; 22 | 23 | if ("Unknown".equals(versionS)) { 24 | return; // Cannot compare versions. 25 | } 26 | 27 | Version currentVersion = Version.parse(versionS); 28 | 29 | URL url = new URL("https://api.github.com/repos/lavalink-devs/youtube-source/releases"); 30 | 31 | try (InputStream body = url.openStream()) { 32 | final JsonArray json = JsonParser.array().from(body); 33 | 34 | Version latestVersion = null; 35 | JsonObject latestRelease = null; 36 | 37 | for (int i = 0; i < json.size(); i++) { 38 | JsonObject release = json.getObject(i); 39 | 40 | if (!release.has("tag_name") || release.isNull("tag_name") || release.getBoolean("draft", false)) { 41 | continue; 42 | } 43 | 44 | Version version = Version.parse(release.getString("tag_name")); 45 | 46 | if (latestVersion == null || version.compareTo(latestVersion) > 0) { 47 | latestVersion = version; 48 | latestRelease = release; 49 | } 50 | } 51 | 52 | if (latestVersion != null && latestVersion.compareTo(currentVersion) > 0) { 53 | log.info("********************************************\n" + 54 | "YOUTUBE-SOURCE VERSION {} AVAILABLE\n" + 55 | "{}\n" + 56 | "Update to ensure the YouTube source remains operational!\n" + 57 | "********************************************", latestVersion, latestRelease.getString("html_url")); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/Pot.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | public class Pot { 4 | private String token; 5 | private String visitorData; 6 | 7 | public String getToken() { 8 | return token != null && !token.isEmpty() ? token : null; 9 | } 10 | 11 | public String getVisitorData() { 12 | return visitorData != null && !visitorData.isEmpty() ? visitorData : null; 13 | } 14 | 15 | public void setToken(String token) { 16 | this.token = token; 17 | } 18 | 19 | public void setVisitorData(String visitorData) { 20 | this.visitorData = visitorData; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | import dev.lavalink.youtube.clients.ClientOptions; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | import org.springframework.stereotype.Component; 6 | 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | 10 | @ConfigurationProperties(prefix = "plugins.youtube") 11 | @Component 12 | public class YoutubeConfig { 13 | private boolean enabled = true; 14 | private boolean allowSearch = true; 15 | private boolean allowDirectVideoIds = true; 16 | private boolean allowDirectPlaylistIds = true; 17 | private Pot pot = null; 18 | private String[] clients; 19 | private Map clientOptions = new HashMap<>(); 20 | private YoutubeOauthConfig oauth = null; 21 | 22 | public boolean getEnabled() { 23 | return enabled; 24 | } 25 | 26 | public boolean getAllowSearch() { 27 | return allowSearch; 28 | } 29 | 30 | public boolean getAllowDirectVideoIds() { 31 | return allowDirectVideoIds; 32 | } 33 | 34 | public boolean getAllowDirectPlaylistIds() { 35 | return allowDirectPlaylistIds; 36 | } 37 | 38 | public Pot getPot() { 39 | return pot; 40 | } 41 | 42 | public String[] getClients() { 43 | return clients; 44 | } 45 | 46 | public Map getClientOptions() { 47 | return clientOptions; 48 | } 49 | 50 | public YoutubeOauthConfig getOauth() { 51 | return this.oauth; 52 | } 53 | 54 | public void setEnabled(boolean enabled) { 55 | this.enabled = enabled; 56 | } 57 | 58 | public void setAllowSearch(boolean allowSearch) { 59 | this.allowSearch = allowSearch; 60 | } 61 | 62 | public void setAllowDirectVideoIds(boolean allowDirectVideoIds) { 63 | this.allowDirectVideoIds = allowDirectVideoIds; 64 | } 65 | 66 | public void setAllowDirectPlaylistIds(boolean allowDirectPlaylistIds) { 67 | this.allowDirectPlaylistIds = allowDirectPlaylistIds; 68 | } 69 | 70 | public void setPot(Pot pot) { 71 | this.pot = pot; 72 | } 73 | 74 | public void setClients(String[] clients) { 75 | this.clients = clients; 76 | } 77 | 78 | public void setClientOptions(Map clientOptions) { 79 | this.clientOptions = clientOptions; 80 | } 81 | 82 | public void setOauth(YoutubeOauthConfig oauth) { 83 | this.oauth = oauth; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeOauthConfig.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | public class YoutubeOauthConfig { 4 | private boolean enabled = false; 5 | private String refreshToken; 6 | private boolean skipInitialization = false; 7 | 8 | public boolean getEnabled() { 9 | return enabled; 10 | } 11 | 12 | public String getRefreshToken() { 13 | return refreshToken; 14 | } 15 | 16 | public boolean getSkipInitialization() { 17 | return skipInitialization; 18 | } 19 | 20 | public void setEnabled(boolean enabled) { 21 | this.enabled = enabled; 22 | } 23 | 24 | public void setRefreshToken(String refreshToken) { 25 | this.refreshToken = refreshToken; 26 | } 27 | 28 | public void setSkipInitialization(boolean skipInitialization) { 29 | this.skipInitialization = skipInitialization; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; 4 | import com.sedmelluq.lava.extensions.youtuberotator.YoutubeIpRotatorSetup; 5 | import com.sedmelluq.lava.extensions.youtuberotator.planner.*; 6 | import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.IpBlock; 7 | import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv4Block; 8 | import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv6Block; 9 | import dev.arbjerg.lavalink.api.AudioPlayerManagerConfiguration; 10 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 11 | import dev.lavalink.youtube.YoutubeSource; 12 | import dev.lavalink.youtube.clients.ClientOptions; 13 | import dev.lavalink.youtube.clients.Web; 14 | import dev.lavalink.youtube.clients.WebEmbedded; 15 | import dev.lavalink.youtube.clients.skeleton.Client; 16 | import lavalink.server.config.RateLimitConfig; 17 | import lavalink.server.config.ServerConfig; 18 | import org.slf4j.Logger; 19 | import org.slf4j.LoggerFactory; 20 | import org.springframework.stereotype.Service; 21 | 22 | import java.lang.reflect.InvocationTargetException; 23 | import java.net.InetAddress; 24 | import java.net.UnknownHostException; 25 | import java.util.*; 26 | import java.util.function.Predicate; 27 | import java.util.stream.Collectors; 28 | 29 | @Service 30 | public class YoutubePluginLoader implements AudioPlayerManagerConfiguration { 31 | private static final Logger log = LoggerFactory.getLogger(YoutubePluginLoader.class); 32 | 33 | private final YoutubeConfig youtubeConfig; 34 | private final ServerConfig serverConfig; 35 | private final RateLimitConfig ratelimitConfig; 36 | private final ClientProvider clientProvider; 37 | 38 | // This entire thing is a hack BTW. Designed to support Lavalink v3 and v4 39 | // with a single plugin. Totally worth it! 40 | public YoutubePluginLoader(final YoutubeConfig youtubeConfig, 41 | final ServerConfig serverConfig) { 42 | this.youtubeConfig = youtubeConfig; 43 | this.serverConfig = serverConfig; 44 | this.ratelimitConfig = serverConfig.getRatelimit(); 45 | 46 | final String providerName = isV4OrNewer() 47 | ? "ClientProviderV4" 48 | : "ClientProviderV3"; 49 | 50 | ClientProvider provider = null; 51 | 52 | try { 53 | provider = getClientProvider(providerName); 54 | } catch (Throwable t) { 55 | log.error("Failed to initialise ClientProvider class with name {}", providerName, t); 56 | } 57 | 58 | this.clientProvider = provider; 59 | 60 | try { 61 | PluginInfo.checkForNewRelease(); 62 | } catch (Throwable ignored) { 63 | 64 | } 65 | } 66 | 67 | private ClientProvider getClientProvider(String name) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { 68 | Class klass = Class.forName("dev.lavalink.youtube.plugin." + name); 69 | return (ClientProvider) klass.getDeclaredConstructor().newInstance(); 70 | } 71 | 72 | private boolean isV4OrNewer() { 73 | try { 74 | Class.forName("com.sedmelluq.discord.lavaplayer.tools.ThumbnailTools"); 75 | return true; 76 | } catch (ClassNotFoundException ignored) { 77 | return false; 78 | } 79 | } 80 | 81 | private ClientOptions getOptionsForClient(String clientName) { 82 | Map clientOptions = youtubeConfig != null ? youtubeConfig.getClientOptions() : null; 83 | 84 | if (clientOptions == null || !clientOptions.containsKey(clientName)) { 85 | return ClientOptions.DEFAULT; 86 | } 87 | 88 | return clientOptions.get(clientName); 89 | } 90 | 91 | private IpBlock getIpBlock(String cidr) { 92 | if (Ipv4Block.isIpv4CidrBlock(cidr)) { 93 | return new Ipv4Block(cidr); 94 | } else if (Ipv6Block.isIpv6CidrBlock(cidr)) { 95 | return new Ipv6Block(cidr); 96 | } else { 97 | throw new RuntimeException("Invalid IP Block '" + cidr + "', make sure to provide a valid CIDR notation"); 98 | } 99 | } 100 | 101 | private AbstractRoutePlanner getRoutePlanner() { 102 | if (ratelimitConfig == null) { 103 | log.debug("No ratelimit config found, skipping setup of route planner"); 104 | return null; 105 | } 106 | 107 | if (ratelimitConfig.getIpBlocks().isEmpty()) { 108 | log.info("Ratelimit config present but no IP blocks were specified, route planner will not initialised."); 109 | return null; 110 | } 111 | 112 | final List excluded = new ArrayList<>(); 113 | 114 | try { 115 | for (String s : ratelimitConfig.getExcludedIps()) { 116 | InetAddress byName = InetAddress.getByName(s); 117 | excluded.add(byName); 118 | } 119 | } catch (UnknownHostException e) { 120 | log.warn("Failed to get excluded IP", e); 121 | } 122 | 123 | final Predicate filter = (ip) -> !excluded.contains(ip); 124 | 125 | final List ipBlocks = ratelimitConfig.getIpBlocks().stream() 126 | .map(this::getIpBlock) 127 | .collect(Collectors.toList()); 128 | 129 | final String strategy = ratelimitConfig.getStrategy().toLowerCase(Locale.getDefault()); 130 | 131 | switch (strategy) { 132 | case "rotateonban": 133 | return new RotatingIpRoutePlanner(ipBlocks, filter, ratelimitConfig.getSearchTriggersFail()); 134 | case "loadbalance": 135 | return new BalancingIpRoutePlanner(ipBlocks, filter, ratelimitConfig.getSearchTriggersFail()); 136 | case "nanoswitch": 137 | return new NanoIpRoutePlanner(ipBlocks, ratelimitConfig.getSearchTriggersFail()); 138 | case "rotatingnanoswitch": 139 | return new RotatingNanoIpRoutePlanner(ipBlocks, filter, ratelimitConfig.getSearchTriggersFail()); 140 | default: 141 | throw new RuntimeException("Unknown strategy '" + strategy + "'!"); 142 | } 143 | } 144 | 145 | @Override 146 | public AudioPlayerManager configure(AudioPlayerManager audioPlayerManager) { 147 | if (youtubeConfig != null && !youtubeConfig.getEnabled()) { 148 | return audioPlayerManager; 149 | } 150 | 151 | final YoutubeAudioSourceManager source; 152 | final boolean allowSearch = youtubeConfig == null || youtubeConfig.getAllowSearch(); 153 | final boolean allowDirectVideoIds = youtubeConfig == null || youtubeConfig.getAllowDirectVideoIds(); 154 | final boolean allowDirectPlaylistIds = youtubeConfig == null || youtubeConfig.getAllowDirectPlaylistIds(); 155 | 156 | if (clientProvider == null) { 157 | log.warn("ClientProvider instance is missing. The YouTube source will be initialised with default clients."); 158 | source = new YoutubeAudioSourceManager(allowSearch, allowDirectVideoIds, allowDirectPlaylistIds); 159 | } else { 160 | String[] clients; 161 | 162 | if (youtubeConfig == null || youtubeConfig.getClients() == null) { 163 | log.warn("youtubeConfig missing or 'clients' was not specified, default values will be used."); 164 | clients = clientProvider.getDefaultClients(); 165 | } else { 166 | clients = youtubeConfig.getClients(); 167 | Pot pot = youtubeConfig.getPot(); 168 | 169 | if (pot != null) { 170 | String token = pot.getToken(); 171 | String visitorData = pot.getVisitorData(); 172 | 173 | if (token != null && visitorData != null) { 174 | log.debug("Applying poToken and visitorData to WEB & WEBEMBEDDED client (token: {}, vd: {})", token, visitorData); 175 | YoutubeSource.setPoTokenAndVisitorData(token, visitorData); 176 | } else if (token != null || visitorData != null) { 177 | log.warn("Both pot.token and pot.visitorData must be specified and valid for pot to apply."); 178 | } 179 | } 180 | } 181 | 182 | source = new YoutubeAudioSourceManager(allowSearch, allowDirectVideoIds, allowDirectPlaylistIds, clientProvider.getClients(clients, this::getOptionsForClient)); 183 | } 184 | 185 | log.info("YouTube source initialised with clients: {} ", Arrays.stream(source.getClients()).map(Client::getIdentifier).collect(Collectors.joining(", "))); 186 | final AbstractRoutePlanner routePlanner = getRoutePlanner(); 187 | 188 | if (routePlanner != null) { 189 | final int retryLimit = ratelimitConfig.getRetryLimit(); 190 | final YoutubeIpRotatorSetup rotator = new YoutubeIpRotatorSetup(routePlanner) 191 | .forConfiguration(source.getHttpInterfaceManager(), false) 192 | .withMainDelegateFilter(source.getContextFilter()); 193 | 194 | if (retryLimit == 0) { 195 | rotator.withRetryLimit(Integer.MAX_VALUE); 196 | } else if (retryLimit > 0) { 197 | rotator.withRetryLimit(retryLimit); 198 | } 199 | 200 | rotator.setup(); 201 | } 202 | 203 | Integer playlistLoadLimit = serverConfig.getYoutubePlaylistLoadLimit(); 204 | 205 | if (playlistLoadLimit != null) { 206 | source.setPlaylistPageCount(playlistLoadLimit); 207 | } 208 | 209 | if (youtubeConfig != null && youtubeConfig.getOauth() != null) { 210 | YoutubeOauthConfig oauthConfig = youtubeConfig.getOauth(); 211 | 212 | if (oauthConfig.getEnabled()) { 213 | log.debug("Configuring youtube oauth integration with token: \"{}\" skipInitialization: {}", oauthConfig.getRefreshToken(), oauthConfig.getSkipInitialization()); 214 | source.useOauth2(oauthConfig.getRefreshToken(), oauthConfig.getSkipInitialization()); 215 | } 216 | } 217 | 218 | audioPlayerManager.registerSourceManager(source); 219 | return audioPlayerManager; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin; 2 | 3 | import com.grack.nanojson.JsonObject; 4 | import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; 5 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 6 | import dev.lavalink.youtube.CannotBeLoaded; 7 | import dev.lavalink.youtube.ClientInformation; 8 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 9 | import dev.lavalink.youtube.clients.Web; 10 | import dev.lavalink.youtube.clients.WebEmbedded; 11 | import dev.lavalink.youtube.clients.skeleton.Client; 12 | import dev.lavalink.youtube.plugin.rest.MinimalConfigRequest; 13 | import dev.lavalink.youtube.plugin.rest.MinimalConfigResponse; 14 | import dev.lavalink.youtube.track.YoutubePersistentHttpStream; 15 | import dev.lavalink.youtube.track.format.StreamFormat; 16 | import dev.lavalink.youtube.track.format.TrackFormats; 17 | import org.slf4j.Logger; 18 | import org.slf4j.LoggerFactory; 19 | import org.springframework.http.HttpStatus; 20 | import org.springframework.http.MediaType; 21 | import org.springframework.http.ResponseEntity; 22 | import org.springframework.stereotype.Service; 23 | import org.springframework.web.bind.annotation.*; 24 | import org.springframework.web.server.ResponseStatusException; 25 | import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; 26 | 27 | import java.io.IOException; 28 | import java.net.URI; 29 | import java.util.Arrays; 30 | 31 | @Service 32 | @RestController 33 | public class YoutubeRestHandler { 34 | private static final Logger log = LoggerFactory.getLogger(YoutubeRestHandler.class); 35 | 36 | private final AudioPlayerManager playerManager; 37 | 38 | public YoutubeRestHandler(AudioPlayerManager playerManager) { 39 | this.playerManager = playerManager; 40 | } 41 | 42 | private YoutubeAudioSourceManager getYoutubeSource() { 43 | YoutubeAudioSourceManager source = playerManager.source(YoutubeAudioSourceManager.class); 44 | 45 | if (source == null) { 46 | throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The YouTube source manager is not registered."); 47 | } 48 | 49 | return source; 50 | } 51 | 52 | @GetMapping("/youtube/stream/{videoId}") 53 | public ResponseEntity getYoutubeVideoStream(@PathVariable("videoId") String videoId, 54 | @RequestParam(name = "itag", required = false) Integer itag, 55 | @RequestParam(name = "withClient", required = false) String clientIdentifier) throws IOException { 56 | YoutubeAudioSourceManager source = getYoutubeSource(); 57 | Throwable lastException = null; 58 | 59 | if (Arrays.stream(source.getClients()).noneMatch(Client::supportsFormatLoading)) { 60 | throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "None of the registered clients supports format loading."); 61 | } 62 | 63 | boolean foundFormats = false; 64 | 65 | HttpInterface httpInterface = source.getInterface(); 66 | 67 | for (Client client : source.getClients()) { 68 | log.debug("REST streaming {} attempting to use client {}", videoId, client.getIdentifier()); 69 | 70 | if (clientIdentifier != null && !client.getIdentifier().equalsIgnoreCase(clientIdentifier)) { 71 | log.debug("Client identifier specified but does not match, trying next."); 72 | continue; 73 | } 74 | 75 | if (!client.supportsFormatLoading()) { 76 | continue; 77 | } 78 | 79 | log.debug("Loading formats for {} with client {}", videoId, client.getIdentifier()); 80 | httpInterface.getContext().setAttribute(Client.OAUTH_CLIENT_ATTRIBUTE, client.supportsOAuth()); 81 | 82 | TrackFormats formats; 83 | 84 | try { 85 | formats = client.loadFormats(source, httpInterface, videoId); 86 | } catch (CannotBeLoaded cbl) { 87 | throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This video cannot be loaded. Reason: " + cbl.getCause().getMessage()); 88 | } catch (Throwable t) { 89 | log.debug("Client \"{}\" threw a non-fatal exception, storing and proceeding...", client.getIdentifier()); 90 | t.addSuppressed(ClientInformation.create(client)); 91 | lastException = t; 92 | continue; 93 | } 94 | 95 | if (formats == null || formats.getFormats().isEmpty()) { 96 | log.debug("No formats found for {}", videoId); 97 | continue; 98 | } 99 | 100 | foundFormats = true; 101 | StreamFormat selectedFormat; 102 | 103 | if (itag == null) { 104 | selectedFormat = formats.getBestFormat(); 105 | } else { 106 | selectedFormat = formats.getFormats().stream().filter(fmt -> fmt.getItag() == itag).findFirst() 107 | .orElse(null); 108 | } 109 | 110 | if (selectedFormat == null) { 111 | log.debug("No suitable formats found. (Matching: {})", itag); 112 | continue; 113 | } 114 | 115 | log.debug("Selected format {} for {}", selectedFormat.getItag(), videoId); 116 | 117 | URI transformed = selectedFormat.getUrl(); 118 | if (client.requirePlayerScript()) { 119 | URI resolved = source.getCipherManager().resolveFormatUrl(httpInterface, formats.getPlayerScriptUrl(), selectedFormat); 120 | transformed = client.transformPlaybackUri(selectedFormat.getUrl(), resolved); 121 | } 122 | 123 | YoutubePersistentHttpStream httpStream = new YoutubePersistentHttpStream(httpInterface, transformed, selectedFormat.getContentLength()); 124 | 125 | boolean streamValidated = false; 126 | 127 | try { 128 | int statusCode = httpStream.checkStatusCode(); 129 | streamValidated = statusCode == 200; 130 | 131 | if (statusCode != 200) { 132 | log.debug("REST streaming with {} for {} returned status code {} when opening video stream", client.getIdentifier(), videoId, statusCode); 133 | } 134 | } catch (Throwable t) { 135 | if ("Not success status code: 403".equals(t.getMessage())) { 136 | log.debug("REST streaming with {} for {} returned status code 403 when opening video stream", client.getIdentifier(), videoId); 137 | } else { 138 | IOUtils.closeQuietly(httpStream, httpInterface); 139 | throw t; 140 | } 141 | } 142 | 143 | if (!streamValidated) { 144 | IOUtils.closeQuietly(httpStream); 145 | continue; 146 | } 147 | 148 | StreamingResponseBody buffer = (os) -> { 149 | int bytesRead; 150 | byte[] copy = new byte[1024]; 151 | 152 | try (httpStream; httpInterface) { 153 | while ((bytesRead = httpStream.read(copy, 0, copy.length)) != -1) { 154 | os.write(copy, 0, bytesRead); 155 | } 156 | } 157 | }; 158 | 159 | return ResponseEntity.ok() 160 | .contentLength(selectedFormat.getContentLength()) 161 | .contentType(MediaType.parseMediaType(selectedFormat.getType().getMimeType())) 162 | .body(buffer); 163 | } 164 | 165 | IOUtils.closeQuietly(httpInterface); 166 | 167 | if (foundFormats) { 168 | throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No formats found with the requested itag."); 169 | } 170 | 171 | if (lastException != null) { 172 | throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "This video cannot be loaded", lastException); 173 | } 174 | 175 | throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not find formats for the requested videoId."); 176 | } 177 | 178 | @GetMapping("/youtube") 179 | public MinimalConfigResponse getYoutubeConfig() { 180 | return MinimalConfigResponse.from(getYoutubeSource()); 181 | } 182 | 183 | @GetMapping("/youtube/oauth/{refreshToken}") 184 | public JsonObject createNewAccessToken(@PathVariable("refreshToken") String refreshToken) { 185 | return getYoutubeSource().getOauth2Handler().createNewAccessToken(refreshToken); 186 | } 187 | 188 | @PostMapping("/youtube") 189 | @ResponseStatus(HttpStatus.NO_CONTENT) 190 | public void updateYoutubeConfig(@RequestBody MinimalConfigRequest config) { 191 | YoutubeAudioSourceManager source = getYoutubeSource(); 192 | String refreshToken = config.getRefreshToken(); 193 | 194 | if (!"x".equals(refreshToken)) { 195 | source.useOauth2(refreshToken, config.getSkipInitialization()); 196 | log.debug("Updated YouTube OAuth2 refresh token to \"{}\"", config.getRefreshToken()); 197 | } 198 | 199 | String poToken = config.getPoToken(); 200 | String visitorData = config.getVisitorData(); 201 | 202 | if (poToken == null || visitorData == null || (!poToken.isEmpty() && !visitorData.isEmpty())) { 203 | WebEmbedded.setPoTokenAndVisitorData(poToken, visitorData); 204 | Web.setPoTokenAndVisitorData(poToken, visitorData); 205 | log.debug("Updated poToken to \"{}\" and visitorData to \"{}\"", poToken, visitorData); 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/rest/MinimalConfigRequest.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin.rest; 2 | 3 | public class MinimalConfigRequest { 4 | private String refreshToken = "x"; // null is a valid value so we have a default placeholder. 5 | private boolean skipInitialization = true; 6 | private String poToken = null; 7 | private String visitorData = null; 8 | 9 | public String getRefreshToken() { 10 | return this.refreshToken; 11 | } 12 | 13 | public boolean getSkipInitialization() { 14 | return this.skipInitialization; 15 | } 16 | 17 | public String getPoToken() { 18 | return this.poToken; 19 | } 20 | 21 | public String getVisitorData() { 22 | return this.visitorData; 23 | } 24 | 25 | public void setRefreshToken(String refreshToken) { 26 | this.refreshToken = refreshToken; 27 | } 28 | 29 | public void setSkipInitialization(boolean skipInitialization) { 30 | this.skipInitialization = skipInitialization; 31 | } 32 | 33 | public void setPoToken(String poToken) { 34 | this.poToken = poToken; 35 | } 36 | 37 | public void setVisitorData(String visitorData) { 38 | this.visitorData = visitorData; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /plugin/src/main/java/dev/lavalink/youtube/plugin/rest/MinimalConfigResponse.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.plugin.rest; 2 | 3 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | public class MinimalConfigResponse { 7 | @Nullable 8 | public String refreshToken; 9 | 10 | private MinimalConfigResponse(@Nullable String refreshToken) { 11 | this.refreshToken = refreshToken; 12 | } 13 | 14 | public static MinimalConfigResponse from(YoutubeAudioSourceManager sourceManager) { 15 | return new MinimalConfigResponse(sourceManager.getOauth2RefreshToken()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /plugin/src/main/resources/yts-version.txt: -------------------------------------------------------------------------------- 1 | @version@ -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "youtube-source" 2 | 3 | include("v2") 4 | include("common") 5 | include("plugin") 6 | 7 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 8 | 9 | dependencyResolutionManagement { 10 | versionCatalogs { 11 | create("libs") { 12 | version("lavaplayer-v1", "1.5.3") 13 | version("lavaplayer-v2", "2.1.1") 14 | 15 | library("lavaplayer-v1", "dev.arbjerg", "lavaplayer").versionRef("lavaplayer-v1") 16 | library("lavaplayer-v2", "dev.arbjerg", "lavaplayer").versionRef("lavaplayer-v2") 17 | 18 | version("lavalink", "3.7.11") 19 | library("lavalink-server", "dev.arbjerg.lavalink", "Lavalink-Server").versionRef("lavalink") 20 | library("lavaplayer-ext-youtube-rotator", "dev.arbjerg", "lavaplayer-ext-youtube-rotator").versionRef("lavaplayer-v1") 21 | 22 | library("rhino-engine", "org.mozilla", "rhino-engine").version("1.7.15") 23 | library("nanojson", "com.grack", "nanojson").version("1.7") 24 | library("slf4j", "org.slf4j", "slf4j-api").version("1.7.25") 25 | library("annotations", "org.jetbrains", "annotations").version("24.1.0") 26 | 27 | plugin("lavalink-gradle-plugin", "dev.arbjerg.lavalink.gradle-plugin").version("1.0.15") 28 | 29 | val mavenPublishPlugin = version("maven-publish-plugin", "0.25.3") 30 | plugin("maven-publish", "com.vanniktech.maven.publish").versionRef(mavenPublishPlugin) 31 | plugin("maven-publish-base", "com.vanniktech.maven.publish.base").versionRef(mavenPublishPlugin) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /v2/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.JavaLibrary 2 | import com.vanniktech.maven.publish.JavadocJar 3 | import org.apache.tools.ant.filters.ReplaceTokens 4 | 5 | plugins { 6 | `java-library` 7 | alias(libs.plugins.maven.publish.base) 8 | } 9 | 10 | base { 11 | archivesName = "youtube-v2" 12 | } 13 | 14 | dependencies { 15 | api(projects.common) 16 | compileOnly(libs.lavaplayer.v2) 17 | 18 | implementation(libs.rhino.engine) 19 | implementation(libs.nanojson) 20 | compileOnly(libs.slf4j) 21 | compileOnly(libs.annotations) 22 | 23 | testImplementation(libs.lavaplayer.v2) 24 | } 25 | 26 | mavenPublishing { 27 | configure(JavaLibrary(JavadocJar.Javadoc())) 28 | } 29 | 30 | tasks { 31 | processResources { 32 | filter( 33 | "tokens" to mapOf( 34 | "version" to project.version 35 | ) 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/AndroidMusicWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public class AndroidMusicWithThumbnail extends AndroidMusic implements NonMusicClientWithThumbnail { 7 | public AndroidMusicWithThumbnail() { 8 | super(); 9 | } 10 | 11 | public AndroidMusicWithThumbnail(@NotNull ClientOptions options) { 12 | super(options); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/AndroidVrWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public class AndroidVrWithThumbnail extends AndroidVr implements NonMusicClientWithThumbnail { 7 | public AndroidVrWithThumbnail() { 8 | super(); 9 | } 10 | 11 | public AndroidVrWithThumbnail(@NotNull ClientOptions options) { 12 | super(options); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/AndroidWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public class AndroidWithThumbnail extends Android implements NonMusicClientWithThumbnail { 7 | public AndroidWithThumbnail() { 8 | super(); 9 | } 10 | 11 | public AndroidWithThumbnail(@NotNull ClientOptions options) { 12 | super(options); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/IosWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public class IosWithThumbnail extends Ios implements NonMusicClientWithThumbnail { 7 | public IosWithThumbnail() { 8 | super(); 9 | } 10 | 11 | public IosWithThumbnail(@NotNull ClientOptions options) { 12 | super(options); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/MWebWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public class MWebWithThumbnail extends MWeb implements NonMusicClientWithThumbnail { 7 | public MWebWithThumbnail() { 8 | super(); 9 | } 10 | 11 | public MWebWithThumbnail(@NotNull ClientOptions options) { 12 | super(options); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/MusicWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 4 | import com.sedmelluq.discord.lavaplayer.tools.ThumbnailTools; 5 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack; 6 | import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; 7 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 8 | import org.jetbrains.annotations.NotNull; 9 | 10 | public class MusicWithThumbnail extends Music { 11 | public MusicWithThumbnail() { 12 | super(); 13 | } 14 | 15 | public MusicWithThumbnail(@NotNull ClientOptions options) { 16 | super(options); 17 | } 18 | 19 | @Override 20 | @NotNull 21 | public AudioTrack buildAudioTrack(@NotNull YoutubeAudioSourceManager source, 22 | @NotNull JsonBrowser json, 23 | @NotNull String title, 24 | @NotNull String author, 25 | long duration, 26 | @NotNull String videoId, 27 | boolean isStream) { 28 | JsonBrowser thumbnailJson = json.get("musicResponsiveListItemRenderer").get("thumbnail").get("musicThumbnailRenderer"); 29 | String thumbnail = ThumbnailTools.getYouTubeMusicThumbnail(thumbnailJson, videoId); 30 | return source.buildAudioTrack(new AudioTrackInfo(title, author, duration, videoId, isStream, WATCH_URL + videoId, thumbnail, null)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/TvHtml5EmbeddedWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public class TvHtml5EmbeddedWithThumbnail extends TvHtml5Embedded implements NonMusicClientWithThumbnail { 7 | public TvHtml5EmbeddedWithThumbnail() { 8 | super(); 9 | } 10 | 11 | public TvHtml5EmbeddedWithThumbnail(@NotNull ClientOptions options) { 12 | super(options); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/WebEmbeddedWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public class WebEmbeddedWithThumbnail extends WebEmbedded implements NonMusicClientWithThumbnail { 7 | public WebEmbeddedWithThumbnail() { 8 | super(); 9 | } 10 | 11 | public WebEmbeddedWithThumbnail(@NotNull ClientOptions options) { 12 | super(options); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/WebWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients; 2 | 3 | import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public class WebWithThumbnail extends Web implements NonMusicClientWithThumbnail { 7 | public WebWithThumbnail() { 8 | super(); 9 | } 10 | 11 | public WebWithThumbnail(@NotNull ClientOptions options) { 12 | super(options); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/skeleton/NonMusicClientWithThumbnail.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients.skeleton; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 4 | import com.sedmelluq.discord.lavaplayer.tools.ThumbnailTools; 5 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack; 6 | import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; 7 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 8 | import org.jetbrains.annotations.NotNull; 9 | 10 | public interface NonMusicClientWithThumbnail extends Client { 11 | @Override 12 | @NotNull 13 | default AudioTrack buildAudioTrack(@NotNull YoutubeAudioSourceManager source, 14 | @NotNull JsonBrowser json, 15 | @NotNull String title, 16 | @NotNull String author, 17 | long duration, 18 | @NotNull String videoId, 19 | boolean isStream) { 20 | String thumbnail = ThumbnailTools.getYouTubeThumbnail(json, videoId); 21 | return source.buildAudioTrack(new AudioTrackInfo(title, author, duration, videoId, isStream, WATCH_URL + videoId, thumbnail, null)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/skeleton/ThumbnailMusicClient.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients.skeleton; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 5 | import com.sedmelluq.discord.lavaplayer.tools.ThumbnailTools; 6 | import com.sedmelluq.discord.lavaplayer.track.*; 7 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 8 | import org.jetbrains.annotations.NotNull; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | 15 | /** 16 | * This class is deprecated. 17 | */ 18 | public abstract class ThumbnailMusicClient extends MusicClient { 19 | private static final Logger log = LoggerFactory.getLogger(ThumbnailMusicClient.class); 20 | 21 | @Override 22 | @NotNull 23 | protected List extractSearchResultTracks(@NotNull YoutubeAudioSourceManager source, 24 | @NotNull JsonBrowser json) { 25 | List tracks = new ArrayList<>(); 26 | 27 | for (JsonBrowser track : json.values()) { 28 | JsonBrowser thumbnail = track.get("musicResponsiveListItemRenderer").get("thumbnail").get("musicThumbnailRenderer"); 29 | JsonBrowser columns = track.get("musicResponsiveListItemRenderer").get("flexColumns"); 30 | 31 | if (columns.isNull()) { 32 | continue; 33 | } 34 | 35 | JsonBrowser metadata = columns.index(0) 36 | .get("musicResponsiveListItemFlexColumnRenderer") 37 | .get("text") 38 | .get("runs") 39 | .index(0); 40 | 41 | String title = metadata.get("text").text(); 42 | String videoId = metadata.get("navigationEndpoint").get("watchEndpoint").get("videoId").text(); 43 | 44 | if (videoId == null) { 45 | // If the track is not available on YouTube Music, videoId will be empty 46 | continue; 47 | } 48 | 49 | List runs = columns.index(1) 50 | .get("musicResponsiveListItemFlexColumnRenderer") 51 | .get("text") 52 | .get("runs") 53 | .values(); 54 | 55 | String author = runs.get(0).get("text").text(); 56 | 57 | if (author == null) { 58 | log.debug("Author field is null, client: {}, json: {}", getIdentifier(), json.format()); 59 | author = "Unknown artist"; 60 | } 61 | 62 | JsonBrowser lastElement = runs.get(runs.size() - 1); 63 | 64 | if (!lastElement.get("navigationEndpoint").isNull()) { 65 | // The duration element should not have this key. If it does, 66 | // then duration is probably missing. 67 | continue; 68 | } 69 | 70 | long duration = DataFormatTools.durationTextToMillis(lastElement.get("text").text()); 71 | String thumbnailUrl = ThumbnailTools.getYouTubeMusicThumbnail(thumbnail, videoId); 72 | 73 | AudioTrackInfo info = new AudioTrackInfo(title, author, duration, videoId, false, WATCH_URL + videoId, thumbnailUrl, null); 74 | tracks.add(source.buildAudioTrack(info)); 75 | } 76 | 77 | return tracks; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/skeleton/ThumbnailNonMusicClient.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients.skeleton; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 5 | import com.sedmelluq.discord.lavaplayer.tools.ThumbnailTools; 6 | import com.sedmelluq.discord.lavaplayer.tools.Units; 7 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 8 | import com.sedmelluq.discord.lavaplayer.track.AudioItem; 9 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack; 10 | import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; 11 | import dev.lavalink.youtube.CannotBeLoaded; 12 | import dev.lavalink.youtube.OptionDisabledException; 13 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 14 | import dev.lavalink.youtube.track.TemporalInfo; 15 | import org.jetbrains.annotations.NotNull; 16 | import org.jetbrains.annotations.Nullable; 17 | import org.slf4j.Logger; 18 | import org.slf4j.LoggerFactory; 19 | 20 | import java.io.IOException; 21 | import java.util.List; 22 | 23 | /** 24 | * The base class for a client that is used for everything except music.youtube.com. 25 | * This class is deprecated. 26 | * Extend the non-thumbnail counterpart and override the {@link Client#buildAudioTrack(YoutubeAudioSourceManager, JsonBrowser, String, String, long, String, boolean)} 27 | * method instead. 28 | */ 29 | public abstract class ThumbnailNonMusicClient extends NonMusicClient { 30 | private static final Logger log = LoggerFactory.getLogger(ThumbnailNonMusicClient.class); 31 | 32 | protected void extractPlaylistTracks(@NotNull JsonBrowser json, 33 | @NotNull List tracks, 34 | @NotNull YoutubeAudioSourceManager source) { 35 | if (!json.get("contents").isNull()) { 36 | json = json.get("contents"); 37 | } 38 | 39 | if (json.isNull()) { 40 | return; 41 | } 42 | 43 | for (JsonBrowser track : json.values()) { 44 | JsonBrowser item = track.get("playlistVideoRenderer"); 45 | JsonBrowser authorJson = item.get("shortBylineText"); 46 | 47 | // isPlayable is null -> video has been removed/blocked 48 | // author is null -> video is region blocked 49 | if (!item.get("isPlayable").isNull() && !authorJson.isNull()) { 50 | String videoId = item.get("videoId").text(); 51 | JsonBrowser titleField = item.get("title"); 52 | String title = DataFormatTools.defaultOnNull(titleField.get("simpleText").text(), titleField.get("runs").index(0).get("text").text()); 53 | String author = DataFormatTools.defaultOnNull(authorJson.get("runs").index(0).get("text").text(), "Unknown artist"); 54 | long duration = Units.secondsToMillis(item.get("lengthSeconds").asLong(Units.DURATION_SEC_UNKNOWN)); 55 | String thumbnailUrl = ThumbnailTools.getYouTubeThumbnail(item, videoId); 56 | 57 | AudioTrackInfo info = new AudioTrackInfo(title, author, duration, videoId, false, WATCH_URL + videoId, thumbnailUrl, null); 58 | tracks.add(source.buildAudioTrack(info)); 59 | } 60 | } 61 | } 62 | 63 | @Nullable 64 | protected AudioTrack extractAudioTrack(@NotNull JsonBrowser json, 65 | @NotNull YoutubeAudioSourceManager source) { 66 | // Ignore if it's not a track or if it's a livestream 67 | if (json.isNull() || json.get("lengthText").isNull() || !json.get("unplayableText").isNull()) return null; 68 | 69 | String videoId = json.get("videoId").text(); 70 | JsonBrowser titleJson = json.get("title"); 71 | String title = DataFormatTools.defaultOnNull(titleJson.get("runs").index(0).get("text").text(), titleJson.get("simpleText").text()); 72 | String author = json.get("longBylineText").get("runs").index(0).get("text").text(); 73 | 74 | if (author == null) { 75 | log.debug("Author field is null, client: {}, json: {}", getIdentifier(), json.format()); 76 | author = "Unknown artist"; 77 | } 78 | 79 | JsonBrowser durationJson = json.get("lengthText"); 80 | String durationText = DataFormatTools.defaultOnNull(durationJson.get("runs").index(0).get("text").text(), durationJson.get("simpleText").text()); 81 | 82 | long duration = DataFormatTools.durationTextToMillis(durationText); 83 | String thumbnailUrl = ThumbnailTools.getYouTubeThumbnail(json, videoId); 84 | 85 | AudioTrackInfo info = new AudioTrackInfo(title, author, duration, videoId, false, WATCH_URL + videoId, thumbnailUrl, null); 86 | return source.buildAudioTrack(info); 87 | } 88 | 89 | @Override 90 | public AudioItem loadVideo(@NotNull YoutubeAudioSourceManager source, 91 | @NotNull HttpInterface httpInterface, 92 | @NotNull String videoId) throws CannotBeLoaded, IOException { 93 | if (!getOptions().getVideoLoading()) { 94 | throw new OptionDisabledException("Video loading is disabled for this client"); 95 | } 96 | 97 | JsonBrowser json = loadTrackInfoFromInnertube(source, httpInterface, videoId, null, false); 98 | JsonBrowser playabilityStatus = json.get("playabilityStatus"); 99 | JsonBrowser videoDetails = json.get("videoDetails"); 100 | 101 | String title = videoDetails.get("title").text(); 102 | String author = videoDetails.get("author").text(); 103 | 104 | if (author == null) { 105 | log.debug("Author field is null, client: {}, json: {}", getIdentifier(), json.format()); 106 | author = "Unknown artist"; 107 | } 108 | 109 | TemporalInfo temporalInfo = TemporalInfo.fromRawData(playabilityStatus, videoDetails); 110 | String thumbnailUrl = ThumbnailTools.getYouTubeThumbnail(videoDetails, videoId); 111 | 112 | AudioTrackInfo info = new AudioTrackInfo(title, author, temporalInfo.durationMillis, videoId, temporalInfo.isActiveStream, WATCH_URL + videoId, thumbnailUrl, null); 113 | return source.buildAudioTrack(info); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /v2/src/main/java/dev/lavalink/youtube/clients/skeleton/ThumbnailStreamingNonMusicClient.java: -------------------------------------------------------------------------------- 1 | package dev.lavalink.youtube.clients.skeleton; 2 | 3 | import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; 4 | import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; 5 | import com.sedmelluq.discord.lavaplayer.tools.Units; 6 | import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; 7 | import dev.lavalink.youtube.CannotBeLoaded; 8 | import dev.lavalink.youtube.YoutubeAudioSourceManager; 9 | import dev.lavalink.youtube.cipher.SignatureCipherManager.CachedPlayerScript; 10 | import dev.lavalink.youtube.track.format.StreamFormat; 11 | import dev.lavalink.youtube.track.format.TrackFormats; 12 | import org.apache.http.entity.ContentType; 13 | import org.jetbrains.annotations.NotNull; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | 17 | import java.io.IOException; 18 | import java.util.ArrayList; 19 | import java.util.Collections; 20 | import java.util.List; 21 | import java.util.Map; 22 | 23 | import static com.sedmelluq.discord.lavaplayer.tools.DataFormatTools.decodeUrlEncodedItems; 24 | import static com.sedmelluq.discord.lavaplayer.tools.Units.CONTENT_LENGTH_UNKNOWN; 25 | 26 | /** 27 | * This class is deprecated. 28 | * Extend the non-thumbnail counterpart and override the {@link Client#buildAudioTrack(YoutubeAudioSourceManager, JsonBrowser, String, String, long, String, boolean)} 29 | * method instead. 30 | */ 31 | public abstract class ThumbnailStreamingNonMusicClient extends ThumbnailNonMusicClient { 32 | private static final Logger log = LoggerFactory.getLogger(ThumbnailStreamingNonMusicClient.class); 33 | 34 | protected static String DEFAULT_SIGNATURE_KEY = "signature"; 35 | 36 | @Override 37 | public TrackFormats loadFormats(@NotNull YoutubeAudioSourceManager source, 38 | @NotNull HttpInterface httpInterface, 39 | @NotNull String videoId) throws CannotBeLoaded, IOException { 40 | JsonBrowser json = loadTrackInfoFromInnertube(source, httpInterface, videoId, null, true); 41 | JsonBrowser playabilityStatus = json.get("playabilityStatus"); 42 | JsonBrowser videoDetails = json.get("videoDetails"); 43 | CachedPlayerScript playerScript = source.getCipherManager().getCachedPlayerScript(httpInterface); 44 | 45 | boolean isLive = videoDetails.get("isLive").asBoolean(false); 46 | 47 | if ("OK".equals(playabilityStatus.get("status").text()) && playabilityStatus.get("reason").safeText().contains("This live event has ended")) { 48 | // Long videos after ending of stream don't contain contentLength field as they 49 | // are still being processed by YouTube. 50 | isLive = true; 51 | } 52 | 53 | JsonBrowser streamingData = json.get("streamingData"); 54 | JsonBrowser mergedFormats = streamingData.get("formats"); 55 | JsonBrowser adaptiveFormats = streamingData.get("adaptiveFormats"); 56 | 57 | List formats = new ArrayList<>(); 58 | boolean anyFailures = false; 59 | 60 | for (JsonBrowser merged : mergedFormats.values()) { 61 | anyFailures = anyFailures || !extractFormat(merged, formats, isLive); 62 | } 63 | 64 | for (JsonBrowser adaptive : adaptiveFormats.values()) { 65 | anyFailures = anyFailures || !extractFormat(adaptive, formats, isLive); 66 | } 67 | 68 | if (formats.isEmpty() && anyFailures) { 69 | log.warn("Loading formats either failed to load or were skipped due to missing fields, json: {}", streamingData.format()); 70 | } 71 | 72 | return new TrackFormats(formats, playerScript.url); 73 | } 74 | 75 | protected boolean extractFormat(@NotNull JsonBrowser formatJson, 76 | @NotNull List formats, 77 | boolean isLive) { 78 | if (formatJson.isNull() || !formatJson.isMap()) { 79 | return false; 80 | } 81 | 82 | String url = formatJson.get("url").text(); 83 | String cipher = formatJson.get("signatureCipher").text(); 84 | 85 | Map cipherInfo = cipher != null 86 | ? decodeUrlEncodedItems(cipher, true) 87 | : Collections.emptyMap(); 88 | 89 | Map urlMap = DataFormatTools.isNullOrEmpty(url) 90 | ? decodeUrlEncodedItems(cipherInfo.get("url"), false) 91 | : decodeUrlEncodedItems(url, false); 92 | 93 | try { 94 | long contentLength = formatJson.get("contentLength").asLong(CONTENT_LENGTH_UNKNOWN); 95 | 96 | if (contentLength == CONTENT_LENGTH_UNKNOWN && !isLive) { 97 | log.debug("Track is not a live stream, but no contentLength in format {}, skipping", formatJson.format()); 98 | return true; // this isn't considered fatal. 99 | } 100 | 101 | formats.add(new StreamFormat( 102 | ContentType.parse(formatJson.get("mimeType").text()), 103 | (int) formatJson.get("itag").asLong(-1L), 104 | formatJson.get("bitrate").asLong(Units.BITRATE_UNKNOWN), 105 | contentLength, 106 | formatJson.get("audioChannels").asLong(2), 107 | cipherInfo.getOrDefault("url", url), 108 | urlMap.get("n"), 109 | cipherInfo.get("s"), 110 | cipherInfo.getOrDefault("sp", DEFAULT_SIGNATURE_KEY), 111 | formatJson.get("audioTrack").get("audioIsDefault").asBoolean(true), 112 | formatJson.get("isDrc").asBoolean(false) 113 | )); 114 | 115 | return true; 116 | } catch (RuntimeException e) { 117 | log.debug("Failed to parse format {}, skipping", formatJson, e); 118 | return false; 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /v2/src/main/resources/yts-version.txt: -------------------------------------------------------------------------------- 1 | @version@ --------------------------------------------------------------------------------