From f2c167f2ddeb45ba61f8f72d808f3f2a80a8f722 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Tue, 21 Mar 2023 16:03:26 +0100 Subject: [PATCH] fix: use url parser instead of regex for extracting track type --- .../youtube/YoutubeParsingHelper.java | 308 ++++++++++-------- .../extractors/YoutubeStreamExtractor.java | 19 +- .../youtube/YoutubeParsingHelperTest.java | 23 +- 3 files changed, 200 insertions(+), 150 deletions(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java index a6a5deffd..534962606 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java @@ -43,6 +43,7 @@ import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.extractor.stream.AudioTrackType; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.utils.JsonUtils; import org.schabi.newpipe.extractor.utils.Parser; @@ -436,7 +437,7 @@ public final class YoutubeParsingHelper { /** * @param playlistId the playlist id to parse * @return the {@link PlaylistInfo.PlaylistType} extracted from the playlistId (mix playlist - * types included) + * types included) * @throws ParsingException if the playlistId is null or empty, if the playlistId is not a mix, * if it is a mix but it's not based on a specific stream (this is the * case for channel or genre mixes) @@ -469,7 +470,7 @@ public final class YoutubeParsingHelper { // 11 characters then it can't be a video id, hence we are dealing with a different // type of mix (e.g. genre mixes handled above, of the form RDGMEM{garbage}) throw new ParsingException("Video id could not be determined from mix id: " - + playlistId); + + playlistId); } return playlistId.substring(2); @@ -482,7 +483,7 @@ public final class YoutubeParsingHelper { /** * @param playlistId the playlist id to parse * @return the {@link PlaylistInfo.PlaylistType} extracted from the playlistId (mix playlist - * types included) + * types included) * @throws ParsingException if the playlistId is null or empty */ @Nonnull @@ -510,7 +511,7 @@ public final class YoutubeParsingHelper { /** * @param playlistUrl the playlist url to parse * @return the {@link PlaylistInfo.PlaylistType} extracted from the playlistUrl's list param - * (mix playlist types included) + * (mix playlist types included) * @throws ParsingException if the playlistUrl is malformed, if has no list param or if the list * param is empty */ @@ -540,20 +541,20 @@ public final class YoutubeParsingHelper { } // @formatter:off final byte[] body = JsonWriter.string() - .object() + .object() .object("context") - .object("client") - .value("hl", "en-GB") - .value("gl", "GB") - .value("clientName", "WEB") - .value("clientVersion", HARDCODED_CLIENT_VERSION) - .end() + .object("client") + .value("hl", "en-GB") + .value("gl", "GB") + .value("clientName", "WEB") + .value("clientVersion", HARDCODED_CLIENT_VERSION) + .end() .object("user") - .value("lockedSafetyMode", false) + .value("lockedSafetyMode", false) .end() .value("fetchLiveState", true) .end() - .end().done().getBytes(StandardCharsets.UTF_8); + .end().done().getBytes(StandardCharsets.UTF_8); // @formatter:on final var headers = getClientHeaders("1", HARDCODED_CLIENT_VERSION); @@ -637,14 +638,14 @@ public final class YoutubeParsingHelper { throw new ParsingException( // CHECKSTYLE:OFF "Could not extract YouTube WEB InnerTube API key from HTML search results page"); - // CHECKSTYLE:ON + // CHECKSTYLE:ON } if (clientVersion == null) { throw new ParsingException( // CHECKSTYLE:OFF "Could not extract YouTube WEB InnerTube client version from HTML search results page"); - // CHECKSTYLE:ON + // CHECKSTYLE:ON } keyAndVersionExtracted = true; @@ -771,30 +772,30 @@ public final class YoutubeParsingHelper { // @formatter:off final byte[] json = JsonWriter.string() - .object() + .object() .object("context") - .object("client") - .value("clientName", "WEB_REMIX") - .value("clientVersion", HARDCODED_YOUTUBE_MUSIC_KEY[2]) - .value("hl", "en-GB") - .value("gl", "GB") - .array("experimentIds").end() - .value("experimentsToken", "") - .object("locationInfo").end() - .object("musicAppInfo").end() - .end() - .object("capabilities").end() - .object("request") - .array("internalExperimentFlags").end() - .object("sessionIndex").end() - .end() - .object("activePlayers").end() - .object("user") - .value("enableSafetyMode", false) - .end() + .object("client") + .value("clientName", "WEB_REMIX") + .value("clientVersion", HARDCODED_YOUTUBE_MUSIC_KEY[2]) + .value("hl", "en-GB") + .value("gl", "GB") + .array("experimentIds").end() + .value("experimentsToken", "") + .object("locationInfo").end() + .object("musicAppInfo").end() + .end() + .object("capabilities").end() + .object("request") + .array("internalExperimentFlags").end() + .object("sessionIndex").end() + .end() + .object("activePlayers").end() + .object("user") + .value("enableSafetyMode", false) + .end() .end() .value("input", "") - .end().done().getBytes(StandardCharsets.UTF_8); + .end().done().getBytes(StandardCharsets.UTF_8); // @formatter:on final var headers = new HashMap<>(getOriginReferrerHeaders(YOUTUBE_MUSIC_URL)); @@ -838,7 +839,7 @@ public final class YoutubeParsingHelper { musicClientName = Parser.matchGroup1(INNERTUBE_CLIENT_NAME_REGEX, html); } - youtubeMusicKey = new String[] {musicKey, musicClientName, musicClientVersion}; + youtubeMusicKey = new String[]{musicKey, musicClientName, musicClientVersion}; return youtubeMusicKey; } @@ -901,7 +902,7 @@ public final class YoutubeParsingHelper { if (navigationEndpoint.getObject("watchEndpoint").has("startTimeSeconds")) { url.append("&t=") .append(navigationEndpoint.getObject("watchEndpoint") - .getInt("startTimeSeconds")); + .getInt("startTimeSeconds")); } return url.toString(); } @@ -1233,24 +1234,24 @@ public final class YoutubeParsingHelper { // @formatter:off return JsonObject.builder() .object("context") - .object("client") - .value("hl", localization.getLocalizationCode()) - .value("gl", contentCountry.getCountryCode()) - .value("clientName", "WEB") - .value("clientVersion", getClientVersion()) - .value("originalUrl", "https://www.youtube.com") - .value("platform", "DESKTOP") - .end() - .object("request") - .array("internalExperimentFlags") - .end() - .value("useSsl", true) - .end() - .object("user") - // TO DO: provide a way to enable restricted mode with: - // .value("enableSafetyMode", boolean) - .value("lockedSafetyMode", false) - .end() + .object("client") + .value("hl", localization.getLocalizationCode()) + .value("gl", contentCountry.getCountryCode()) + .value("clientName", "WEB") + .value("clientVersion", getClientVersion()) + .value("originalUrl", "https://www.youtube.com") + .value("platform", "DESKTOP") + .end() + .object("request") + .array("internalExperimentFlags") + .end() + .value("useSsl", true) + .end() + .object("user") + // TO DO: provide a way to enable restricted mode with: + // .value("enableSafetyMode", boolean) + .value("lockedSafetyMode", false) + .end() .end(); // @formatter:on } @@ -1262,32 +1263,32 @@ public final class YoutubeParsingHelper { // @formatter:off return JsonObject.builder() .object("context") - .object("client") - .value("clientName", "ANDROID") - .value("clientVersion", ANDROID_YOUTUBE_CLIENT_VERSION) - .value("platform", "MOBILE") - .value("osName", "Android") - .value("osVersion", "12") - /* - A valid Android SDK version is required to be sure to get a valid player - response - If this parameter is not provided, the player response may be replaced by - the one of a 5-minute video saying the message "The following content is - not available on this app. Watch this content on the latest version on - YouTube" - See https://github.com/TeamNewPipe/NewPipe/issues/8713 - The Android SDK version corresponding to the Android version used in - requests is sent - */ - .value("androidSdkVersion", 31) - .value("hl", localization.getLocalizationCode()) - .value("gl", contentCountry.getCountryCode()) - .end() - .object("user") - // TO DO: provide a way to enable restricted mode with: - // .value("enableSafetyMode", boolean) - .value("lockedSafetyMode", false) - .end() + .object("client") + .value("clientName", "ANDROID") + .value("clientVersion", ANDROID_YOUTUBE_CLIENT_VERSION) + .value("platform", "MOBILE") + .value("osName", "Android") + .value("osVersion", "12") + /* + A valid Android SDK version is required to be sure to get a valid player + response + If this parameter is not provided, the player response may be replaced by + the one of a 5-minute video saying the message "The following content is + not available on this app. Watch this content on the latest version on + YouTube" + See https://github.com/TeamNewPipe/NewPipe/issues/8713 + The Android SDK version corresponding to the Android version used in + requests is sent + */ + .value("androidSdkVersion", 31) + .value("hl", localization.getLocalizationCode()) + .value("gl", contentCountry.getCountryCode()) + .end() + .object("user") + // TO DO: provide a way to enable restricted mode with: + // .value("enableSafetyMode", boolean) + .value("lockedSafetyMode", false) + .end() .end(); // @formatter:on } @@ -1299,27 +1300,27 @@ public final class YoutubeParsingHelper { // @formatter:off return JsonObject.builder() .object("context") - .object("client") - .value("clientName", "IOS") - .value("clientVersion", IOS_YOUTUBE_CLIENT_VERSION) - .value("deviceMake", "Apple") - // Device model is required to get 60fps streams - .value("deviceModel", IOS_DEVICE_MODEL) - .value("platform", "MOBILE") - .value("osName", "iOS") - // The value of this field seems to use the following structure: - // "iOS version.0.build version" - // The build version corresponding to the iOS version used can be found on - // https://www.theiphonewiki.com/wiki/Firmware/iPhone/15.x#iPhone_13 - .value("osVersion", "15.6.0.19G71") - .value("hl", localization.getLocalizationCode()) - .value("gl", contentCountry.getCountryCode()) - .end() - .object("user") - // TO DO: provide a way to enable restricted mode with: - // .value("enableSafetyMode", boolean) - .value("lockedSafetyMode", false) - .end() + .object("client") + .value("clientName", "IOS") + .value("clientVersion", IOS_YOUTUBE_CLIENT_VERSION) + .value("deviceMake", "Apple") + // Device model is required to get 60fps streams + .value("deviceModel", IOS_DEVICE_MODEL) + .value("platform", "MOBILE") + .value("osName", "iOS") + // The value of this field seems to use the following structure: + // "iOS version.0.build version" + // The build version corresponding to the iOS version used can be found on + // https://www.theiphonewiki.com/wiki/Firmware/iPhone/15.x#iPhone_13 + .value("osVersion", "15.6.0.19G71") + .value("hl", localization.getLocalizationCode()) + .value("gl", contentCountry.getCountryCode()) + .end() + .object("user") + // TO DO: provide a way to enable restricted mode with: + // .value("enableSafetyMode", boolean) + .value("lockedSafetyMode", false) + .end() .end(); // @formatter:on } @@ -1332,22 +1333,22 @@ public final class YoutubeParsingHelper { // @formatter:off return JsonObject.builder() .object("context") - .object("client") - .value("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER") - .value("clientVersion", TVHTML5_SIMPLY_EMBED_CLIENT_VERSION) - .value("clientScreen", "EMBED") - .value("platform", "TV") - .value("hl", localization.getLocalizationCode()) - .value("gl", contentCountry.getCountryCode()) - .end() - .object("thirdParty") - .value("embedUrl", "https://www.youtube.com/watch?v=" + videoId) - .end() - .object("user") - // TO DO: provide a way to enable restricted mode with: - // .value("enableSafetyMode", boolean) - .value("lockedSafetyMode", false) - .end() + .object("client") + .value("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER") + .value("clientVersion", TVHTML5_SIMPLY_EMBED_CLIENT_VERSION) + .value("clientScreen", "EMBED") + .value("platform", "TV") + .value("hl", localization.getLocalizationCode()) + .value("gl", contentCountry.getCountryCode()) + .end() + .object("thirdParty") + .value("embedUrl", "https://www.youtube.com/watch?v=" + videoId) + .end() + .object("user") + // TO DO: provide a way to enable restricted mode with: + // .value("enableSafetyMode", boolean) + .value("lockedSafetyMode", false) + .end() .end(); // @formatter:on } @@ -1364,19 +1365,19 @@ public final class YoutubeParsingHelper { return JsonWriter.string((isTvHtml5DesktopJsonBuilder ? prepareTvHtml5EmbedJsonBuilder(localization, contentCountry, videoId) : prepareDesktopJsonBuilder(localization, contentCountry)) - .object("playbackContext") - .object("contentPlaybackContext") + .object("playbackContext") + .object("contentPlaybackContext") // Signature timestamp from the JavaScript base player is needed to get // working obfuscated URLs .value("signatureTimestamp", sts) .value("referer", "https://www.youtube.com/watch?v=" + videoId) - .end() - .end() - .value(CPN, contentPlaybackNonce) - .value(VIDEO_ID, videoId) - .value(CONTENT_CHECK_OK, true) - .value(RACY_CHECK_OK, true) - .done()) + .end() + .end() + .value(CPN, contentPlaybackNonce) + .value(VIDEO_ID, videoId) + .value(CONTENT_CHECK_OK, true) + .value(RACY_CHECK_OK, true) + .done()) .getBytes(StandardCharsets.UTF_8); // @formatter:on } @@ -1472,7 +1473,7 @@ public final class YoutubeParsingHelper { * Returns an unmodifiable {@link Map} containing the {@code X-YouTube-Client-Name} and * {@code X-YouTube-Client-Version} headers. * - * @param name The X-YouTube-Client-Name value. + * @param name The X-YouTube-Client-Name value. * @param version X-YouTube-Client-Version value. */ private static Map> getClientHeaders(@Nonnull final String name, @@ -1483,6 +1484,7 @@ public final class YoutubeParsingHelper { /** * Create a map with the required cookie header. + * * @return A singleton map containing the header. */ public static Map> getCookieHeader() { @@ -1801,4 +1803,52 @@ public final class YoutubeParsingHelper { public static boolean isConsentAccepted() { return consentAccepted; } + + private static final Pattern AUDIO_STREAM_TYPE_REGEX = + Pattern.compile("&xtags=[\\w%]*acont(?:=|%3D)([a-z]+)(?:=|%3D|:|%3A|&|$)"); + + /** + * Extract the audio track type from a YouTube stream URL. + *

+ * The track type is parsed from the {@code xtags} URL parameter + * (Example: {@code acont=original:lang=en}). + *

+ * @param streamUrl YouTube stream URL + * @return {@link AudioTrackType} or {@code null} if no track type was found + */ + @Nullable + public static AudioTrackType extractAudioTrackType(final String streamUrl) { + final String xtags; + try { + xtags = Utils.getQueryValue(new URL(streamUrl), "xtags"); + } catch (final MalformedURLException e) { + return null; + } + if (xtags == null) { + return null; + } + + String atype = null; + for (final String param : xtags.split(":")) { + final String[] kv = param.split("=", 2); + if (kv.length > 1 && kv[0].equals("acont")) { + atype = kv[1]; + break; + } + } + if (atype == null) { + return null; + } + + switch (atype) { + case "original": + return AudioTrackType.ORIGINAL; + case "dubbed": + return AudioTrackType.DUBBED; + case "descriptive": + return AudioTrackType.DESCRIPTIVE; + default: + return null; + } + } } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java index d931b2e12..17462aac3 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java @@ -72,7 +72,6 @@ import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; import org.schabi.newpipe.extractor.services.youtube.YoutubeThrottlingDecrypter; import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelLinkHandlerFactory; import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.AudioTrackType; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.Frameset; @@ -100,7 +99,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -812,8 +810,6 @@ public class YoutubeStreamExtractor extends StreamExtractor { "\\bc\\s*&&\\s*d\\.set\\([^,]+\\s*,\\s*(:encodeURIComponent\\s*\\()([a-zA-Z0-9$]+)\\(" }; private static final String STS_REGEX = "signatureTimestamp[=:](\\d+)"; - private static final Pattern AUDIO_STREAM_TYPE_REGEX = - Pattern.compile("&xtags=[\\w%]*acont(?:=|%3D)([a-z]+)(?:=|%3D|:|%3A|&|$)"); @Override public void onFetchPage(@Nonnull final Downloader downloader) @@ -1488,20 +1484,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { itagItem.setAudioLocale(LocaleCompat.forLanguageTag( audioTrackId.substring(0, audioTrackIdLastLocaleCharacter))); } - - try { - final String atype = Parser.matchGroup1(AUDIO_STREAM_TYPE_REGEX, streamUrl); - switch (atype) { - case "original": - itagItem.setAudioTrackType(AudioTrackType.ORIGINAL); - break; - case "dubbed": - itagItem.setAudioTrackType(AudioTrackType.DUBBED); - break; - case "descriptive": - itagItem.setAudioTrackType(AudioTrackType.DESCRIPTIVE); - } - } catch (final Parser.RegexException ignored) { } + itagItem.setAudioTrackType(YoutubeParsingHelper.extractAudioTrackType(streamUrl)); } itagItem.setAudioTrackName(formatData.getObject("audioTrack") diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelperTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelperTest.java index 1fa89e94e..f314d7bf8 100644 --- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelperTest.java +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelperTest.java @@ -1,16 +1,20 @@ package org.schabi.newpipe.extractor.services.youtube; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.schabi.newpipe.downloader.DownloaderFactory; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.stream.AudioTrackType; +import org.schabi.newpipe.extractor.utils.Parser; +import org.schabi.newpipe.extractor.utils.Utils; import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.*; public class YoutubeParsingHelperTest { @@ -48,4 +52,17 @@ public class YoutubeParsingHelperTest { assertEquals("https://www.infektionsschutz.de/coronavirus-sars-cov-2.html", YoutubeParsingHelper.extractCachedUrlIfNeeded("https://www.infektionsschutz.de/coronavirus-sars-cov-2.html")); } + + @Test + public void extractAudioTrackType() { + final String originalUrl = "https://rr2---sn-4g5lzned.googlevideo.com/videoplayback?expire=1679429648&ei=sLsZZKrICIuR1gLSnYbgAg&ip=2001%3A638%3A102%3A26%3A1a7c%3A106b%3A6e4a%3Adc09&id=o-ALWn2ZwDxUXEZKzlsT_X9iuDjRMSi__SgRXVrVjKZEhc&itag=251&source=youtube&requiressl=yes&mh=nU&mm=31%2C29&mn=sn-4g5lzned%2Csn-4g5edndz&ms=au%2Crdu&mv=m&mvi=2&pl=40&initcwndbps=1740000&spc=H3gIhgXQzBxvKu2MOEmFaaEenC4DKdVUwudTeu3dtKwmq-Xv5g&vprv=1&xtags=acont%3Doriginal%3Alang%3Den&mime=audio%2Fwebm&ns=-lg0OQZL1LZRQO-dzE0W4E4L&gir=yes&clen=3513412&dur=303.681&lmt=1679342942566207&mt=1679407764&fvip=1&keepalive=yes&fexp=24007246&c=WEB&txp=5532434&n=gDLP5pImH9Vr7v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cxtags%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgPFQ1yX8aoc35sz2eV2-wzNIhTQeOHGCsOmIonmo776kCIFo5k6HZ5kAQ6DycRCAG8jJgk9jNyncILGPrGZMZUuuo&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhANODPaBuc32MWI9gF3Bn1iz3byEn7EwUiXpNLuCcQqW9AiBB88Qrrz2fJCzYKg14_nnGxGQH1Uoi7i31OSrHK6_dGw%3D%3D"; + final String dubbedUrl = "https://rr2---sn-4g5lzned.googlevideo.com/videoplayback?expire=1679429648&ei=sLsZZKrICIuR1gLSnYbgAg&ip=2001%3A638%3A102%3A26%3A1a7c%3A106b%3A6e4a%3Adc09&id=o-ALWn2ZwDxUXEZKzlsT_X9iuDjRMSi__SgRXVrVjKZEhc&itag=251&source=youtube&requiressl=yes&mh=nU&mm=31%2C29&mn=sn-4g5lzned%2Csn-4g5edndz&ms=au%2Crdu&mv=m&mvi=2&pl=40&initcwndbps=1740000&spc=H3gIhgXQzBxvKu2MOEmFaaEenC4DKdVUwudTeu3dtKwmq-Xv5g&vprv=1&xtags=acont%3Ddubbed%3Alang%3Den&mime=audio%2Fwebm&ns=-lg0OQZL1LZRQO-dzE0W4E4L&gir=yes&clen=3884070&dur=303.721&lmt=1679342946044954&mt=1679407764&fvip=1&keepalive=yes&fexp=24007246&c=WEB&txp=5532434&n=gDLP5pImH9Vr7v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cxtags%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKEMLB8yLZJf2jXAu4P1Q8AVEciYsmjjr2syYAWZfJg6AiAfu-XI11zYpCLqljw_MCegh26pJHYyfatgfFGWfpL-6Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhANODPaBuc32MWI9gF3Bn1iz3byEn7EwUiXpNLuCcQqW9AiBB88Qrrz2fJCzYKg14_nnGxGQH1Uoi7i31OSrHK6_dGw%3D%3D"; + final String descriptiveUrl = "https://rr2---sn-4g5lzned.googlevideo.com/videoplayback?expire=1679429648&ei=sLsZZKrICIuR1gLSnYbgAg&ip=2001%3A638%3A102%3A26%3A1a7c%3A106b%3A6e4a%3Adc09&id=o-ALWn2ZwDxUXEZKzlsT_X9iuDjRMSi__SgRXVrVjKZEhc&itag=251&source=youtube&requiressl=yes&mh=nU&mm=31%2C29&mn=sn-4g5lzned%2Csn-4g5edndz&ms=au%2Crdu&mv=m&mvi=2&pl=40&initcwndbps=1740000&spc=H3gIhgXQzBxvKu2MOEmFaaEenC4DKdVUwudTeu3dtKwmq-Xv5g&vprv=1&xtags=acont%3Ddescriptive%3Alang%3Den&mime=audio%2Fwebm&ns=-lg0OQZL1LZRQO-dzE0W4E4L&gir=yes&clen=4061711&dur=303.721&lmt=1679342946800120&mt=1679407764&fvip=1&keepalive=yes&fexp=24007246&c=WEB&txp=5532434&n=gDLP5pImH9Vr7v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cxtags%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKFUzoNscV1hbNcPwcnQO3vOy47q69szj7BdLhFYS52pAiEA2oPhLZIZsrUQrx62iH4dHvTBlCloC3NieJw6edo7LL8%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhANODPaBuc32MWI9gF3Bn1iz3byEn7EwUiXpNLuCcQqW9AiBB88Qrrz2fJCzYKg14_nnGxGQH1Uoi7i31OSrHK6_dGw%3D%3D"; + final String noTrackUrl = "https://rr2---sn-4g5ednz7.googlevideo.com/videoplayback?expire=1679430240&ei=AL4ZZKiXJefYx_APj_6ECA&ip=2001%3A638%3A102%3A26%3A1a7c%3A106b%3A6e4a%3Adc09&id=o-ALKVh9uHVEvurL3bZOZCEMzFod9ZmJJd6GszA6UEIuKy&itag=251&source=youtube&requiressl=yes&mh=8L&mm=31%2C26&mn=sn-4g5ednz7%2Csn-i5heen7z&ms=au%2Conr&mv=m&mvi=2&pl=40&initcwndbps=1793750&spc=H3gIhh2s06nxQJg3zEgY9pw84syUasRiagYDsQ5UHHfcu5bfTA&vprv=1&mime=audio%2Fwebm&ns=VumObYcnTZNicexX7Ek2WakL&gir=yes&clen=3711099&dur=299.201&lmt=1679334484198077&mt=1679408487&fvip=2&keepalive=yes&fexp=24007246&c=WEB&txp=3318224&n=10c-m6ZvG6C7rC&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAODS0aHRBgdrHm5qwquqGC6zq3rU81W59y4BtV0Y9KStAiAPT8ykXXj_7GzAyZbLPgYKs-B1HWT-4bY0CppmZ2rReg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAL8fS6T-V9BNqrx55mdMvve5be2gcjIY8pYfxlUMPY6pAiAgiCMbqR4eSS_HvLu9KBe6cCFZeMcSTc7vzWtL9y0xvw%3D%3D"; + + assertEquals(AudioTrackType.ORIGINAL, YoutubeParsingHelper.extractAudioTrackType(originalUrl)); + assertEquals(AudioTrackType.DUBBED, YoutubeParsingHelper.extractAudioTrackType(dubbedUrl)); + assertEquals(AudioTrackType.DESCRIPTIVE, YoutubeParsingHelper.extractAudioTrackType(descriptiveUrl)); + assertNull(YoutubeParsingHelper.extractAudioTrackType(noTrackUrl)); + } }