[CHANGE] Add account dialog

This commit is contained in:
max
2025-09-04 18:05:56 +02:00
parent a8cfbbe0db
commit 431a103fac
12 changed files with 382 additions and 111 deletions

View File

@@ -0,0 +1,106 @@
<ForcedLoadingOverlay Visible="_isLoading"/>
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Add new account</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
@if (_showCookieTextImport)
{
<MudTextField @bind-Value="@_cookieDomain" Required HelperText="Domain"/>
<MudTextField AutoGrow @bind-Value="@_cookieText" HelperText="Cookie text" Placeholder="EXAMPLE: Cookie1=Value1; Cookie2=Value2;"/>
<MudButton Variant="Variant.Outlined" Disabled="@(string.IsNullOrWhiteSpace(_cookieDomain) || string.IsNullOrWhiteSpace(_cookieText))" OnClick="ApplyTextCookies">Apply</MudButton>
}
<MudStack Row Spacing="2" AlignItems="AlignItems.Stretch" Justify="Justify.SpaceEvenly" StretchItems="StretchItems.All">
<MudStack Spacing="2" Style="width: 100%">
<MudTextField Label="UserAgent" Required @bind-Value="@Client.UserAgent"/>
</MudStack>
<MudSimpleTable Style="width: 100%" Bordered Dense Elevation="0" Outlined Square Hover>
<tbody>
<tr>
<td>Account id:</td>
<td>@Client.Id</td>
</tr>
<tr>
<td>Account name:</td>
<td>@Client.AccountName</td>
</tr>
<tr>
<td>User agent:</td>
<td>@Client.UserAgent</td>
</tr>
<tr>
<td>Logged in:</td>
<td style="@($"color: {(Client.ClientState?.LoggedIn ?? false ? "green" : "red")}")">@Client.ClientState?.LoggedIn</td>
</tr>
<tr>
<td>InnerTube API key:</td>
<td>@Client.ClientState?.InnertubeApiKey</td>
</tr>
<tr>
<td>InnerTube client version:</td>
<td>@Client.ClientState?.InnerTubeClientVersion</td>
</tr>
<tr>
<td>Language:</td>
<td>@Client.ClientState?.InnerTubeContext?.InnerTubeClient?.HLanguage</td>
</tr>
</tbody>
</MudSimpleTable>
</MudStack>
<MudDataGrid Items="Client.CookieContainer.GetAllCookies()" Dense Elevation="0" Outlined>
<Header>
<MudStack Class="ma-2">
<MudText>Cookies</MudText>
</MudStack>
<MudStack Row Spacing="2" Class="ma-1">
<MudTooltip Text="Add cookie">
<MudIconButton Icon="@Icons.Material.Filled.Add" Size="Size.Small" Color="Color.Success" Disabled="_showCookieTextImport" OnClick="AddCookie"/>
</MudTooltip>
<MudTooltip Text="Add from text">
<MudIconButton Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Color="Color.Primary" OnClick="ToggleCookieTextImport"/>
</MudTooltip>
</MudStack>
</Header>
<Columns>
<TemplateColumn Title="Name">
<CellTemplate>
<MudTextField Variant="Variant.Text" @bind-Value="@context.Item.Name" Immediate/>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="Domain">
<CellTemplate>
<MudTextField Variant="Variant.Text" @bind-Value="@context.Item.Domain" Immediate/>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="Value">
<CellTemplate>
<MudTextField Variant="Variant.Text" @bind-Value="@context.Item.Value" Immediate/>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Title="Expires" Property="x => x.Expires"/>
<TemplateColumn>
<CellTemplate>
<MudTooltip Text="Remove">
<MudIconButton Size="Size.Small" Icon="@Icons.Material.Filled.Remove" Color="Color.Error" OnClick="@(() => RemoveCookie(context.Item))"/>
</MudTooltip>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
</MudStack>
</DialogContent>
<DialogActions>
<MudStack Spacing="2" Row>
<MudButton Color="Color.Error" OnClick="() => MudDialog?.Cancel()" Variant="Variant.Outlined">Cancel</MudButton>
<MudButton Color="Color.Info" Variant="Variant.Outlined" OnClick="ValidateAccount" Disabled="@(!CanValidate())">Validate</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Outlined" Disabled="@(!CanSave())" OnClick="OnSave">Save</MudButton>
</MudStack>
</DialogActions>
</MudDialog>

View File

@@ -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));
}
}
}

View File

@@ -1,61 +1,18 @@
@page "/Channels"
@using Manager.App.Models.Settings
@using Microsoft.Extensions.Options
@inject ILibraryService LibraryService
@inject IDialogService DialogService
@inject IOptions<LibrarySettings> LibraryOptions
<PageTitle>Channels</PageTitle>
<MudDialog @bind-Visible="@_addAccountDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">Add new account</MudText>
</TitleContent>
<DialogContent>
<MudDataGrid Items="_cookies" Dense Elevation="0" Outlined>
<Header>
<MudStack Class="ma-2">
<MudText>Cookies</MudText>
</MudStack>
<MudStack Row Spacing="2" Class="ma-1">
<MudTooltip Text="Add cookie">
<MudIconButton Icon="@Icons.Material.Filled.Add" Size="Size.Small" Color="Color.Success" OnClick="() => _cookies.Add(new HttpCookie())"/>
</MudTooltip>
<MudTooltip Text="Add from text">
<MudIconButton Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Color="Color.Primary" Disabled/>
</MudTooltip>
</MudStack>
</Header>
<Columns>
<TemplateColumn Title="Name">
<CellTemplate>
<MudTextField Variant="Variant.Text" @bind-Value="@context.Item.Name" Immediate/>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="Value">
<CellTemplate>
<MudTextField Variant="Variant.Text" @bind-Value="@context.Item.Value" Immediate/>
</CellTemplate>
</TemplateColumn>
<TemplateColumn>
<CellTemplate>
<MudTooltip Text="Remove">
<MudIconButton Size="Size.Small" Icon="@Icons.Material.Filled.Remove" Color="Color.Error" OnClick="() => _cookies.Remove(context.Item)"/>
</MudTooltip>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
</DialogContent>
<DialogActions>
<MudStack Spacing="2" Row>
<MudButton Color="Color.Error" OnClick="() => _addAccountDialogVisible = false" Variant="Variant.Outlined">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Outlined" Disabled>Save</MudButton>
</MudStack>
</DialogActions>
</MudDialog>
<MudStack Spacing="2">
<MudPaper Elevation="0" Outlined>
<MudStack Row Class="ma-2">
<MudButton IconSize="Size.Small" StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Outlined" OnClick="() => _addAccountDialogVisible = true">Add account</MudButton>
<MudButton IconSize="Size.Small" StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Outlined" OnClick="OnAddAccountDialogAsync">Add account</MudButton>
</MudStack>
</MudPaper>

View File

@@ -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<HttpCookie> _cookies = [];
private readonly DialogOptions _dialogOptions = new() { BackdropClick = false, CloseButton = true, FullWidth = true, MaxWidth = MaxWidth.ExtraLarge };
private async Task<TableData<ChannelEntity>> ServerReload(TableState state, CancellationToken token)
{
var results = await LibraryService.GetChannelAccountsAsync(state.Page * state.PageSize, state.PageSize, token);
if (!results.IsSuccess)
return !results.IsSuccess ? new TableData<ChannelEntity>() : new TableData<ChannelEntity> { Items = results.Value, TotalItems = results.Total };
}
private async Task OnAddAccountDialogAsync()
{
var libSettings = LibraryOptions.Value;
var parameters = new DialogParameters<AccountDialog> { { x => x.DefaultUserAgent, libSettings.DefaultUserAgent } };
var dialog = await DialogService.ShowAsync<AccountDialog>("Add account", parameters, _dialogOptions);
var result = await dialog.Result;
if (result == null || result.Canceled || result.Data == null)
{
return new TableData<ChannelEntity>();
return;
}
return new TableData<ChannelEntity> { Items = results.Value, TotalItems = results.Total };
var client = (YouTubeClient)result.Data;
}
}
public record HttpCookie()
{
public string Name { get; set; }
public string Value { get; set; }
}

View File

@@ -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;

View File

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Manager.YouTube.Models;
public class AdditionalJsonData
{
[JsonExtensionData]
public Dictionary<string, object> AdditionalData { get; set; } = [];
}

View File

@@ -2,17 +2,35 @@ using System.Text.Json.Serialization;
namespace Manager.YouTube.Models.Innertube;
public class ClientState
public class ClientState : AdditionalJsonData
{
[JsonExtensionData]
public Dictionary<string, object> 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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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<Result<ClientState>> 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<ClientState>.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<ClientState>(clientStateResult.Value);
clientState = JsonSerializer.Deserialize<ClientState>(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<Result> 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");
}
}

View File

@@ -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<string> 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}')]");
const string setFunction = "ytcfg.set({";
var scriptNode = htmlDocument.DocumentNode.SelectSingleNode($"//script[contains(., '{setFunction}')]");
if (string.IsNullOrWhiteSpace(scriptNode.InnerText))
return ResultError.Fail($"Could not find {functionName} in html script nodes!");
return ResultError.Fail($"Could not find {setFunction} in html script nodes!");
var regexPattern = $@"{Regex.Escape(functionName)}\(([^)]+)\);";
var match = Regex.Match(scriptNode.InnerText, regexPattern);
var json = ExtractJson(scriptNode.InnerText, "ytcfg.set(");
var jsonText = ExtractJson(scriptNode.InnerText, "setMessage(");
if (match.Success)
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];
}
}

View File

@@ -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()