diff --git a/Manager.YouTube/Models/Innertube/ClientState.cs b/Manager.YouTube/Models/Innertube/ClientState.cs index 3f1d912..e4fab16 100644 --- a/Manager.YouTube/Models/Innertube/ClientState.cs +++ b/Manager.YouTube/Models/Innertube/ClientState.cs @@ -35,6 +35,9 @@ public class ClientState : AdditionalJsonData [JsonPropertyName("SERVER_VERSION")] public string? ServerVersion { get; set; } + [JsonPropertyName("PLAYER_JS_URL")] + public string? PlayerJsUrl { get; set; } + [JsonPropertyName("INNERTUBE_CONTEXT")] public InnerTubeContext? InnerTubeContext { get; set; } diff --git a/Manager.YouTube/Util/Cipher/CipherDecoder.cs b/Manager.YouTube/Util/Cipher/CipherDecoder.cs new file mode 100644 index 0000000..a61a932 --- /dev/null +++ b/Manager.YouTube/Util/Cipher/CipherDecoder.cs @@ -0,0 +1,90 @@ +using System.Text; +using System.Text.RegularExpressions; +using Manager.YouTube.Util.Cipher.Operations; + +namespace Manager.YouTube.Util.Cipher; + +public partial class CipherDecoder +{ + public required string Version { get; init; } + public required string OriginalRelativeUrl { get; init; } + public readonly IReadOnlyCollection Operations; + + private CipherDecoder(IEnumerable operations) + { + Operations = operations.ToList(); + if (Operations.Count == 0) + { + throw new ArgumentNullException(nameof(operations), "No decipher operations given."); + } + } + + public static async Task CreateAsync(string relativeUrl, string version, YouTubeClient? client = null) + { + var operations = await GetCipherOperations(relativeUrl, client); + var decoder = new CipherDecoder(operations) + { + OriginalRelativeUrl = relativeUrl, + Version = version + }; + return decoder; + } + + private static async Task> GetCipherOperations(string relativeUrl, YouTubeClient? client = null) + { + var downloadRequest = new HttpRequestMessage(HttpMethod.Get, new Uri($"{NetworkService.Origin}/{relativeUrl}")); + var downloadResponse = await NetworkService.DownloadBytesAsync(downloadRequest, client); + if (!downloadResponse.IsSuccess) + { + return []; + } + + var playerJs = Encoding.UTF8.GetString(downloadResponse.Value.Data); + if (string.IsNullOrWhiteSpace(playerJs)) + { + return []; + } + + var functionBody = FunctionBodyRegex().Match(playerJs).Groups[0].ToString(); + var definitionBody = DefinitionBodyRegex().Match(functionBody).Groups[1].Value; + var decipherDefinition = Regex.Match(playerJs, $@"var\s+{definitionBody}=\{{(\w+:function\(\w+(,\w+)?\)\{{(.*?)\}}),?\}};", RegexOptions.Singleline).Groups[0].ToString(); + + List operations = []; + foreach (var statement in functionBody.Split(';')) + { + // Get the name of the function called in this statement + var calledFuncName = StatementFunctionNameRegex().Match(statement).Groups[1].Value; + if (string.IsNullOrWhiteSpace(calledFuncName)) + continue; + + if (Regex.IsMatch(decipherDefinition, $@"{Regex.Escape(calledFuncName)}:\bfunction\b\([a],b\).(\breturn\b)?.?\w+\.")) + { + var index = int.Parse(OperationIndexRegex().Match(statement).Groups[1].Value); + operations.Add(new CipherSlice(index)); + } + else if (Regex.IsMatch(decipherDefinition, $@"{Regex.Escape(calledFuncName)}:\bfunction\b\(\w+\,\w\).\bvar\b.\bc=a\b")) + { + var index = int.Parse(OperationIndexRegex().Match(statement).Groups[1].Value); + operations.Add(new CipherSwap(index)); + } + else if (Regex.IsMatch(decipherDefinition, $@"{Regex.Escape(calledFuncName)}:\bfunction\b\(\w+\)")) + { + operations.Add(new CipherReverse()); + } + } + + return []; + } + + + [GeneratedRegex(@"(\w+)=function\(\w+\){(\w+)=\2\.split\(\x22{2}\);.*?return\s+\2\.join\(\x22{2}\)}")] + private static partial Regex FunctionBodyRegex(); + [GeneratedRegex("([\\$_\\w]+).\\w+\\(\\w+,\\d+\\);")] + private static partial Regex DefinitionBodyRegex(); + + + [GeneratedRegex(@"\(\w+,(\d+)\)")] + private static partial Regex OperationIndexRegex(); + [GeneratedRegex(@"\w+(?:.|\[)(\""?\w+(?:\"")?)\]?\(")] + private static partial Regex StatementFunctionNameRegex(); +} \ No newline at end of file diff --git a/Manager.YouTube/Util/Cipher/CipherDecoderCollection.cs b/Manager.YouTube/Util/Cipher/CipherDecoderCollection.cs new file mode 100644 index 0000000..cdc9158 --- /dev/null +++ b/Manager.YouTube/Util/Cipher/CipherDecoderCollection.cs @@ -0,0 +1,11 @@ +using System.Collections.ObjectModel; + +namespace Manager.YouTube.Util.Cipher; + +public class CipherDecoderCollection : KeyedCollection +{ + protected override string GetKeyForItem(CipherDecoder item) + { + return item.Version; + } +} \ No newline at end of file diff --git a/Manager.YouTube/Util/Cipher/CipherManager.cs b/Manager.YouTube/Util/Cipher/CipherManager.cs new file mode 100644 index 0000000..fb06919 --- /dev/null +++ b/Manager.YouTube/Util/Cipher/CipherManager.cs @@ -0,0 +1,46 @@ +using DotBased.Logging; +using DotBased.Monads; + +namespace Manager.YouTube.Util.Cipher; + +public static class CipherManager +{ + private static readonly CipherDecoderCollection LoadedCiphers = []; + private static readonly ILogger Logger = LogService.RegisterLogger(typeof(CipherManager)); + + public static async Task> GetDecoder(YouTubeClient client) + { + var relativePlayerJsUrl = client.State?.PlayerJsUrl; + if (string.IsNullOrEmpty(relativePlayerJsUrl)) + { + return ResultError.Fail("Could not get player js url."); + } + var version = GetCipherVersion(relativePlayerJsUrl); + + Logger.Debug($"Getting cipher decoder for version: {version}"); + if (LoadedCiphers.TryGetValue(version, out var cipher)) + { + return cipher; + } + + try + { + var decoder = await CipherDecoder.CreateAsync(relativePlayerJsUrl, version); + LoadedCiphers.Add(decoder); + } + catch (Exception e) + { + Logger.Error(e, "Could not create cipher decoder. Version: {DecoderVersion}", version); + } + + return ResultError.Fail($"Could not create cipher decoder for {relativePlayerJsUrl} (v: {version})"); + } + + private static string GetCipherVersion(string relativePlayerUrl) + { + var split = relativePlayerUrl.Split('/'); + var v = split[2]; + var lang = split[4]; + return $"{v}_{lang}"; + } +} \ No newline at end of file diff --git a/Manager.YouTube/Util/Cipher/Operations/CipherReverse.cs b/Manager.YouTube/Util/Cipher/Operations/CipherReverse.cs new file mode 100644 index 0000000..cd6de30 --- /dev/null +++ b/Manager.YouTube/Util/Cipher/Operations/CipherReverse.cs @@ -0,0 +1,18 @@ +using System.Text; + +namespace Manager.YouTube.Util.Cipher.Operations; + +public class CipherReverse : ICipherOperation +{ + public string Decipher(string cipherSignature) + { + var buffer = new StringBuilder(cipherSignature.Length); + + for (var i = cipherSignature.Length - 1; i >= 0; i--) + { + buffer.Append(cipherSignature[i]); + } + + return buffer.ToString(); + } +} \ No newline at end of file diff --git a/Manager.YouTube/Util/Cipher/Operations/CipherSlice.cs b/Manager.YouTube/Util/Cipher/Operations/CipherSlice.cs new file mode 100644 index 0000000..12542d2 --- /dev/null +++ b/Manager.YouTube/Util/Cipher/Operations/CipherSlice.cs @@ -0,0 +1,6 @@ +namespace Manager.YouTube.Util.Cipher.Operations; + +public class CipherSlice(int indexToSlice) : ICipherOperation +{ + public string Decipher(string cipherSignature) => cipherSignature[indexToSlice..]; +} \ No newline at end of file diff --git a/Manager.YouTube/Util/Cipher/Operations/CipherSwap.cs b/Manager.YouTube/Util/Cipher/Operations/CipherSwap.cs new file mode 100644 index 0000000..aee7a12 --- /dev/null +++ b/Manager.YouTube/Util/Cipher/Operations/CipherSwap.cs @@ -0,0 +1,12 @@ +using System.Text; + +namespace Manager.YouTube.Util.Cipher.Operations; + +public class CipherSwap(int indexToSwap) : ICipherOperation +{ + public string Decipher(string cipherSignature) => new StringBuilder(cipherSignature) + { + [0] = cipherSignature[indexToSwap], + [indexToSwap] = cipherSignature[0] + }.ToString(); +} \ No newline at end of file diff --git a/Manager.YouTube/Util/Cipher/Operations/ICipherOperation.cs b/Manager.YouTube/Util/Cipher/Operations/ICipherOperation.cs new file mode 100644 index 0000000..e9d76b8 --- /dev/null +++ b/Manager.YouTube/Util/Cipher/Operations/ICipherOperation.cs @@ -0,0 +1,6 @@ +namespace Manager.YouTube.Util.Cipher.Operations; + +public interface ICipherOperation +{ + string Decipher(string cipherSignature); +} \ No newline at end of file