diff --git a/Manager.App/Components/Dialogs/AccountDialog.razor b/Manager.App/Components/Dialogs/AccountDialog.razor new file mode 100644 index 0000000..2a3d75f --- /dev/null +++ b/Manager.App/Components/Dialogs/AccountDialog.razor @@ -0,0 +1,106 @@ + + + + + Add new account + + + + @if (_showCookieTextImport) + { + + + + Apply + } + + + + + + + + + Account id: + @Client.Id + + + Account name: + @Client.AccountName + + + User agent: + @Client.UserAgent + + + + Logged in: + @Client.ClientState?.LoggedIn + + + InnerTube API key: + @Client.ClientState?.InnertubeApiKey + + + InnerTube client version: + @Client.ClientState?.InnerTubeClientVersion + + + Language: + @Client.ClientState?.InnerTubeContext?.InnerTubeClient?.HLanguage + + + + + + +
+ + Cookies + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Cancel + Validate + Save + + +
\ No newline at end of file diff --git a/Manager.App/Components/Dialogs/AccountDialog.razor.cs b/Manager.App/Components/Dialogs/AccountDialog.razor.cs new file mode 100644 index 0000000..b174a35 --- /dev/null +++ b/Manager.App/Components/Dialogs/AccountDialog.razor.cs @@ -0,0 +1,117 @@ +using System.Net; +using Manager.YouTube; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace Manager.App.Components.Dialogs +{ + public partial class AccountDialog : ComponentBase + { + [CascadingParameter] private IMudDialogInstance? MudDialog { get; set; } + [Parameter] public string DefaultUserAgent { get; set; } = ""; + + public YouTubeClient Client { get; set; } = new(); + + private bool _isLoading; + + private bool _showCookieTextImport; + private string _cookieText = ""; + private string _cookieDomain = ".youtube.com"; + + protected override void OnInitialized() + { + Client.UserAgent = DefaultUserAgent; + base.OnInitialized(); + } + + private void AddCookie() + { + Client.CookieContainer.Add(new Cookie { Name = "SET_NAME", Domain = ".youtube.com" }); + } + + private async Task RemoveCookie(Cookie? cookie) + { + if (cookie == null) + { + return; + } + + cookie.Expired = true; + await InvokeAsync(StateHasChanged); + } + + private void ToggleCookieTextImport() + { + _showCookieTextImport =! _showCookieTextImport; + } + + private void ApplyTextCookies() + { + _showCookieTextImport = false; + var cookies = ParseCookieHeader(_cookieText, _cookieDomain); + Client.CookieContainer.Add(cookies); + _cookieText = string.Empty; + StateHasChanged(); + } + + public static CookieCollection ParseCookieHeader(string cookieHeader, string domain = "") + { + var collection = new CookieCollection(); + + if (string.IsNullOrWhiteSpace(cookieHeader)) + return collection; + + var cookies = cookieHeader.Split(';', StringSplitOptions.RemoveEmptyEntries); + + foreach (var cookieStr in cookies) + { + var parts = cookieStr.Split('=', 2); + if (parts.Length == 2) + { + var name = parts[0].Trim(); + var value = parts[1].Trim(); + + // Escape invalid characters + var safeName = Uri.EscapeDataString(name); + var safeValue = Uri.EscapeDataString(value); + + var cookie = new Cookie(safeName, safeValue); + + if (!string.IsNullOrEmpty(domain)) + cookie.Domain = domain; + + collection.Add(cookie); + } + } + + return collection; + } + + private bool CanValidate() + { + if (string.IsNullOrWhiteSpace(Client.UserAgent) || Client.CookieContainer.Count <= 0) + { + return false; + } + + return true; + } + + private bool CanSave() + { + return Client.ClientState is { LoggedIn: true }; + } + + private async Task ValidateAccount() + { + _isLoading = true; + await Client.GetStateAsync(); + _isLoading = false; + } + + private void OnSave() + { + MudDialog?.Close(DialogResult.Ok(Client)); + } + } +} \ No newline at end of file diff --git a/Manager.App/Components/Pages/Channels.razor b/Manager.App/Components/Pages/Channels.razor index 2e0b35d..f508973 100644 --- a/Manager.App/Components/Pages/Channels.razor +++ b/Manager.App/Components/Pages/Channels.razor @@ -1,61 +1,18 @@ @page "/Channels" +@using Manager.App.Models.Settings +@using Microsoft.Extensions.Options + @inject ILibraryService LibraryService +@inject IDialogService DialogService +@inject IOptions LibraryOptions + Channels - - - Add new account - - - -
- - Cookies - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - -
-
- - - Cancel - Save - - -
- - Add account + Add account diff --git a/Manager.App/Components/Pages/Channels.razor.cs b/Manager.App/Components/Pages/Channels.razor.cs index 349bae6..e31e2f6 100644 --- a/Manager.App/Components/Pages/Channels.razor.cs +++ b/Manager.App/Components/Pages/Channels.razor.cs @@ -1,4 +1,6 @@ +using Manager.App.Components.Dialogs; using Manager.Data.Entities.LibraryContext; +using Manager.YouTube; using Microsoft.AspNetCore.Components; using MudBlazor; @@ -6,23 +8,26 @@ namespace Manager.App.Components.Pages; public partial class Channels : ComponentBase { - private bool _addAccountDialogVisible; - private DialogOptions _dialogOptions = new() { BackdropClick = false, CloseButton = true, FullWidth = true }; - private List _cookies = []; + private readonly DialogOptions _dialogOptions = new() { BackdropClick = false, CloseButton = true, FullWidth = true, MaxWidth = MaxWidth.ExtraLarge }; + private async Task> ServerReload(TableState state, CancellationToken token) { var results = await LibraryService.GetChannelAccountsAsync(state.Page * state.PageSize, state.PageSize, token); - if (!results.IsSuccess) - { - return new TableData(); - } - - return new TableData { Items = results.Value, TotalItems = results.Total }; + return !results.IsSuccess ? new TableData() : new TableData { Items = results.Value, TotalItems = results.Total }; } -} + + private async Task OnAddAccountDialogAsync() + { + var libSettings = LibraryOptions.Value; + var parameters = new DialogParameters { { x => x.DefaultUserAgent, libSettings.DefaultUserAgent } }; + var dialog = await DialogService.ShowAsync("Add account", parameters, _dialogOptions); + var result = await dialog.Result; -public record HttpCookie() -{ - public string Name { get; set; } - public string Value { get; set; } + if (result == null || result.Canceled || result.Data == null) + { + return; + } + + var client = (YouTubeClient)result.Data; + } } \ No newline at end of file diff --git a/Manager.App/Services/System/ClientManager.cs b/Manager.App/Services/System/ClientManager.cs index 0085859..64a00d8 100644 --- a/Manager.App/Services/System/ClientManager.cs +++ b/Manager.App/Services/System/ClientManager.cs @@ -41,7 +41,9 @@ public class ClientManager : BackgroundService container.Add(cookieColl); } - var ytClient = new YouTubeClient(container, accountEntity.UserAgent ?? ""); + var ytClient = new YouTubeClient(); + //ytClient.CookieContainer = container; + ytClient.UserAgent = accountEntity.UserAgent; await ytClient.GetStateAsync(); return ytClient; diff --git a/Manager.YouTube/Models/AdditionalJsonData.cs b/Manager.YouTube/Models/AdditionalJsonData.cs new file mode 100644 index 0000000..46f4a6a --- /dev/null +++ b/Manager.YouTube/Models/AdditionalJsonData.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Manager.YouTube.Models; + +public class AdditionalJsonData +{ + [JsonExtensionData] + public Dictionary AdditionalData { get; set; } = []; +} \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/ClientState.cs b/Manager.YouTube/Models/Innertube/ClientState.cs index 8195f39..f7b7bfc 100644 --- a/Manager.YouTube/Models/Innertube/ClientState.cs +++ b/Manager.YouTube/Models/Innertube/ClientState.cs @@ -2,17 +2,35 @@ using System.Text.Json.Serialization; namespace Manager.YouTube.Models.Innertube; -public class ClientState +public class ClientState : AdditionalJsonData { - [JsonExtensionData] - public Dictionary AdditionalData { get; set; } = []; - [JsonPropertyName("INNERTUBE_API_KEY")] public string? InnertubeApiKey { get; set; } + + [JsonPropertyName("LINK_API_KEY")] + public string? LinkApiKey { get; set; } + + [JsonPropertyName("VOZ_API_KEY")] + public string? VozApiKey { get; set; } [JsonPropertyName("SIGNIN_URL")] public string? SigninUrl { get; set; } + [JsonPropertyName("INNERTUBE_CLIENT_VERSION")] + public string? InnerTubeClientVersion { get; set; } + + [JsonPropertyName("LOGGED_IN")] + public bool LoggedIn { get; set; } + + [JsonPropertyName("USER_ACCOUNT_NAME")] + public string? UserAccountName { get; set; } + + [JsonPropertyName("SERVER_VERSION")] + public string? ServerVersion { get; set; } + + [JsonPropertyName("INNERTUBE_CONTEXT")] + public InnerTubeContext? InnerTubeContext { get; set; } + [JsonPropertyName("SBOX_SETTINGS")] public SBoxSettings? SBoxSettings { get; set; } } \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/InnerTubeClient.cs b/Manager.YouTube/Models/Innertube/InnerTubeClient.cs new file mode 100644 index 0000000..ea7a8ff --- /dev/null +++ b/Manager.YouTube/Models/Innertube/InnerTubeClient.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Manager.YouTube.Models.Innertube; + +public class InnerTubeClient : AdditionalJsonData +{ + [JsonPropertyName("hl")] + public string? HLanguage { get; set; } + [JsonPropertyName("gl")] + public string? GLanguage { get; set; } + [JsonPropertyName("remoteHost")] + public string? RemoteHost { get; set; } + [JsonPropertyName("rolloutToken")] + public string? RolloutToken { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/Models/Innertube/InnerTubeContext.cs b/Manager.YouTube/Models/Innertube/InnerTubeContext.cs new file mode 100644 index 0000000..ee3d472 --- /dev/null +++ b/Manager.YouTube/Models/Innertube/InnerTubeContext.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Manager.YouTube.Models.Innertube; + +public class InnerTubeContext : AdditionalJsonData +{ + [JsonPropertyName("client")] + public InnerTubeClient? InnerTubeClient { get; set; } +} \ No newline at end of file diff --git a/Manager.YouTube/NetworkService.cs b/Manager.YouTube/NetworkService.cs index 29dafe8..c327412 100644 --- a/Manager.YouTube/NetworkService.cs +++ b/Manager.YouTube/NetworkService.cs @@ -2,28 +2,26 @@ using System.Text.Json; using DotBased.Monads; using Manager.YouTube.Models.Innertube; using Manager.YouTube.Parsers; -using Manager.YouTube.Util; namespace Manager.YouTube; public static class NetworkService { + private const string Origin = "https://www.youtube.com/"; + public static async Task> GetClientStateAsync(YouTubeClient client) { - var origin = "https://www.youtube.com/"; var httpRequest = new HttpRequestMessage { Method = HttpMethod.Get, - RequestUri = new Uri(origin) + RequestUri = new Uri(Origin) }; httpRequest.Headers.IfModifiedSince = new DateTimeOffset(DateTime.UtcNow); httpRequest.Headers.UserAgent.ParseAdd(client.UserAgent); - - if (client.SapisidCookie != null) - { - httpRequest.Headers.Authorization = AuthenticationUtilities.GetSapisidHashHeader(client.SapisidCookie.Value, origin); - httpRequest.Headers.Add("Origin", origin); - } + httpRequest.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + httpRequest.Headers.Connection.Add("keep-alive"); + httpRequest.Headers.Add("DNT", "1"); + httpRequest.Headers.Add("Upgrade-Insecure-Requests", "1"); var http = client.GetHttpClient(); if (http == null) @@ -38,7 +36,7 @@ public static class NetworkService return Result.Fail(ResultError.Fail(responseResult)); } var responseHtml = await response.Content.ReadAsStringAsync(); - var clientStateResult = HtmlParser.GetJsonFromScriptFunction(responseHtml, "ytcfg.set"); + var clientStateResult = HtmlParser.GetStateJson(responseHtml); if (clientStateResult is { IsSuccess: false, Error: not null }) { return clientStateResult.Error; @@ -47,7 +45,7 @@ public static class NetworkService ClientState? clientState; try { - clientState = JsonSerializer.Deserialize(clientStateResult.Value); + clientState = JsonSerializer.Deserialize(clientStateResult.Value.Item1); } catch (Exception e) { @@ -56,4 +54,22 @@ public static class NetworkService return clientState == null ? ResultError.Fail("Unable to parse client state!") : clientState; } + + public static async Task GetCurrentAccountAsync() + { + //URL: /youtubei/v1/account/account_menu + // Payload + // "context": { + // "client": {CLIENT INFO FROM STATE} + // } + + /* Auth header + * if (client.SapisidCookie != null) + { + httpRequest.Headers.Authorization = AuthenticationUtilities.GetSapisidHashHeader(client.SapisidCookie.Value, origin); + httpRequest.Headers.Add("Origin", origin); + } + */ + return ResultError.Fail("Not implemented"); + } } \ No newline at end of file diff --git a/Manager.YouTube/Parsers/HtmlParser.cs b/Manager.YouTube/Parsers/HtmlParser.cs index 0a69c11..844d4c2 100644 --- a/Manager.YouTube/Parsers/HtmlParser.cs +++ b/Manager.YouTube/Parsers/HtmlParser.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using DotBased.Monads; using HtmlAgilityPack; @@ -6,34 +5,60 @@ namespace Manager.YouTube.Parsers; public static class HtmlParser { - public static Result GetJsonFromScriptFunction(string html, string functionName) + public static Result<(string, string)> GetStateJson(string html) { if (string.IsNullOrWhiteSpace(html)) { return ResultError.Fail("html cannot be empty!"); } - - if (string.IsNullOrWhiteSpace(functionName)) - { - return ResultError.Fail("No function names provided!"); - } var htmlDocument = new HtmlDocument(); htmlDocument.LoadHtml(html); - - var scriptNode = htmlDocument.DocumentNode.SelectSingleNode($"//script[contains(., '{functionName}')]"); - if (string.IsNullOrWhiteSpace(scriptNode.InnerText)) - return ResultError.Fail($"Could not find {functionName} in html script nodes!"); - - var regexPattern = $@"{Regex.Escape(functionName)}\(([^)]+)\);"; - var match = Regex.Match(scriptNode.InnerText, regexPattern); - if (match.Success) + const string setFunction = "ytcfg.set({"; + var scriptNode = htmlDocument.DocumentNode.SelectSingleNode($"//script[contains(., '{setFunction}')]"); + if (string.IsNullOrWhiteSpace(scriptNode.InnerText)) + return ResultError.Fail($"Could not find {setFunction} in html script nodes!"); + + var json = ExtractJson(scriptNode.InnerText, "ytcfg.set("); + var jsonText = ExtractJson(scriptNode.InnerText, "setMessage("); + + if (string.IsNullOrWhiteSpace(json) || string.IsNullOrWhiteSpace(jsonText)) { - var jsonString = match.Groups[1].Value.Trim(); - return jsonString; + return ResultError.Fail($"Could not find {setFunction} in html script nodes!"); } - return ResultError.Fail($"Unable to parse {functionName} JSON!"); + return (json, jsonText); + } + + static string? ExtractJson(string input, string marker) + { + var start = input.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) return null; + + start += marker.Length; + + // Skip until first '{' + while (start < input.Length && input[start] != '{') + start++; + + if (start >= input.Length) return null; + + var depth = 0; + var i = start; + + for (; i < input.Length; i++) + { + if (input[i] == '{') depth++; + else if (input[i] == '}') + { + depth--; + if (depth != 0) continue; + i++; + break; + } + } + + return input[start..i]; } } \ No newline at end of file diff --git a/Manager.YouTube/YouTubeClient.cs b/Manager.YouTube/YouTubeClient.cs index c0ac540..47fbae3 100644 --- a/Manager.YouTube/YouTubeClient.cs +++ b/Manager.YouTube/YouTubeClient.cs @@ -1,33 +1,27 @@ using System.Net; -using DotBased.Logging; using Manager.YouTube.Models.Innertube; namespace Manager.YouTube; public sealed class YouTubeClient : IDisposable { - public string Id { get; private set; } - public string AccountName { get; private set; } - public string? UserAgent { get; private set; } - public CookieContainer CookieContainer { get; } + public string Id { get; private set; } = ""; + public string AccountName => ClientState?.UserAccountName ?? ""; + public string? UserAgent { get; set; } + public CookieContainer CookieContainer { get; } = new(); public ClientState? ClientState { get; private set; } public Cookie? SapisidCookie => CookieContainer.GetAllCookies()["SAPISID"]; public HttpClient? GetHttpClient() => _httpClient; - private readonly ILogger? _logger; private HttpClient? _httpClient; - public YouTubeClient(CookieContainer cookieContainer, string userAgent, ILogger? logger = null) + public YouTubeClient() { - CookieContainer = cookieContainer; - _logger = logger; - UserAgent = userAgent; SetupClient(); } private void SetupClient() { - _logger?.Information("Building http client..."); _httpClient?.Dispose(); var clientHandler = new HttpClientHandler @@ -44,12 +38,10 @@ public sealed class YouTubeClient : IDisposable var state = await NetworkService.GetClientStateAsync(this); if (!state.IsSuccess) { - _logger?.Warning("Error getting client state: {StateError}", state.Error); return; } ClientState = state.Value; - _logger?.Information("Client state retrieved. With API key: {InnertubeApiKey}", ClientState.InnertubeApiKey); } public void Dispose()