Compare commits
3 Commits
dd19fc27d9
...
431a103fac
Author | SHA1 | Date | |
---|---|---|---|
|
431a103fac | ||
|
a8cfbbe0db | ||
|
f7bfee5de2 |
106
Manager.App/Components/Dialogs/AccountDialog.razor
Normal file
106
Manager.App/Components/Dialogs/AccountDialog.razor
Normal 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>
|
117
Manager.App/Components/Dialogs/AccountDialog.razor.cs
Normal file
117
Manager.App/Components/Dialogs/AccountDialog.razor.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavLink Href="/" Match="NavLinkMatch.Prefix">Home</MudNavLink>
|
<MudNavLink Href="/" Icon="@Icons.Material.Filled.Home" Match="NavLinkMatch.All">Home</MudNavLink>
|
||||||
<MudNavLink Href="/Accounts" Match="NavLinkMatch.All">Accounts</MudNavLink>
|
<MudNavLink Href="/Channels" Icon="@Icons.Material.Filled.SupervisorAccount" Match="NavLinkMatch.All">Channels</MudNavLink>
|
||||||
<MudNavLink Href="/Library" Match="NavLinkMatch.All">Library</MudNavLink>
|
<MudNavLink Href="/Library" Icon="@Icons.Material.Filled.LocalLibrary" Match="NavLinkMatch.All">Library</MudNavLink>
|
||||||
<MudNavLink Href="/Playlists" Match="NavLinkMatch.All">Playlists</MudNavLink>
|
<MudNavLink Href="/Playlists" Icon="@Icons.Material.Filled.ViewList" Match="NavLinkMatch.All">Playlists</MudNavLink>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
@@ -1,4 +0,0 @@
|
|||||||
@page "/Accounts"
|
|
||||||
|
|
||||||
<PageTitle>Accounts</PageTitle>
|
|
||||||
|
|
@@ -1,8 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
|
|
||||||
namespace Manager.App.Components.Pages;
|
|
||||||
|
|
||||||
public partial class Accounts : ComponentBase
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
43
Manager.App/Components/Pages/Channels.razor
Normal file
43
Manager.App/Components/Pages/Channels.razor
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@page "/Channels"
|
||||||
|
@using Manager.App.Models.Settings
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
|
|
||||||
|
@inject ILibraryService LibraryService
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject IOptions<LibrarySettings> LibraryOptions
|
||||||
|
|
||||||
|
<PageTitle>Channels</PageTitle>
|
||||||
|
|
||||||
|
|
||||||
|
<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="OnAddAccountDialogAsync">Add account</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudTable ServerData="ServerReload">
|
||||||
|
<ToolBarContent>
|
||||||
|
<MudText Typo="Typo.h6">Channels</MudText>
|
||||||
|
</ToolBarContent>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Name</MudTh>
|
||||||
|
<MudTh>Channel id</MudTh>
|
||||||
|
<MudTh>Has login</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Name</MudTd>
|
||||||
|
<MudTd>@context.Id</MudTd>
|
||||||
|
<MudTd>@(context.ClientAccount != null)</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText>No channels found</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
<LoadingContent>
|
||||||
|
<MudText>Loading...</MudText>
|
||||||
|
</LoadingContent>
|
||||||
|
<PagerContent>
|
||||||
|
<MudTablePager/>
|
||||||
|
</PagerContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudStack>
|
33
Manager.App/Components/Pages/Channels.razor.cs
Normal file
33
Manager.App/Components/Pages/Channels.razor.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using Manager.App.Components.Dialogs;
|
||||||
|
using Manager.Data.Entities.LibraryContext;
|
||||||
|
using Manager.YouTube;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using MudBlazor;
|
||||||
|
|
||||||
|
namespace Manager.App.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Channels : ComponentBase
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = (YouTubeClient)result.Data;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,9 +1,10 @@
|
|||||||
using Manager.App.Models.Library;
|
using Manager.App.Models.Library;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
using MudBlazor;
|
using MudBlazor;
|
||||||
|
|
||||||
namespace Manager.App.Components.Pages;
|
namespace Manager.App.Components.Pages;
|
||||||
|
|
||||||
public partial class Library
|
public partial class Library : ComponentBase
|
||||||
{
|
{
|
||||||
private LibraryInformation? _libraryInformation;
|
private LibraryInformation? _libraryInformation;
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
|
30
Manager.App/Models/System/ListResult.cs
Normal file
30
Manager.App/Models/System/ListResult.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using DotBased.Monads;
|
||||||
|
|
||||||
|
namespace Manager.App.Models.System;
|
||||||
|
|
||||||
|
public class ListResult<TResult> : Result<List<TResult>>
|
||||||
|
{
|
||||||
|
protected ListResult(List<TResult> result, int total) : base(result)
|
||||||
|
{
|
||||||
|
Total = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ListResult(Exception exception) : base(exception)
|
||||||
|
{
|
||||||
|
Total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ListResult(ResultError error) : base(error)
|
||||||
|
{
|
||||||
|
Total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
public static implicit operator ListResult<TResult>(ResultError error) => new(error);
|
||||||
|
public static implicit operator ListResult<TResult>(Exception exception) => new(exception);
|
||||||
|
public static implicit operator ListResult<TResult>(List<TResult> result) => new(result, 0);
|
||||||
|
public static implicit operator ListResult<TResult>(ListResultReturn<TResult> result) => new(result.List, result.Total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ListResultReturn<TResult>(List<TResult> List, int Total);
|
@@ -1,9 +1,13 @@
|
|||||||
using DotBased.Monads;
|
using DotBased.Monads;
|
||||||
using Manager.App.Models.Library;
|
using Manager.App.Models.Library;
|
||||||
|
using Manager.App.Models.System;
|
||||||
|
using Manager.Data.Entities.LibraryContext;
|
||||||
|
|
||||||
namespace Manager.App.Services;
|
namespace Manager.App.Services;
|
||||||
|
|
||||||
public interface ILibraryService
|
public interface ILibraryService
|
||||||
{
|
{
|
||||||
public Task<Result<LibraryInformation>> GetLibraryInfoAsync(CancellationToken cancellationToken = default);
|
public Task<Result<LibraryInformation>> GetLibraryInfoAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
public Task<ListResult<ChannelEntity>> GetChannelAccountsAsync(int total = 20, int offset = 0, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
@@ -1,6 +1,7 @@
|
|||||||
using DotBased.Monads;
|
using DotBased.Monads;
|
||||||
using Manager.App.Models.Library;
|
using Manager.App.Models.Library;
|
||||||
using Manager.App.Models.Settings;
|
using Manager.App.Models.Settings;
|
||||||
|
using Manager.App.Models.System;
|
||||||
using Manager.Data.Contexts;
|
using Manager.Data.Contexts;
|
||||||
using Manager.Data.Entities.LibraryContext;
|
using Manager.Data.Entities.LibraryContext;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -50,13 +51,13 @@ public class LibraryService : ILibraryService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<ChannelEntity>>> GetAccountsAsync(int total = 20, int offset = 0, CancellationToken cancellationToken = default)
|
public async Task<ListResult<ChannelEntity>> GetChannelAccountsAsync(int total = 20, int offset = 0, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
var orderedAccounts = context.Channels.Include(x => x.ClientAccount).Where(x => x.ClientAccount != null).OrderBy(x => x.Id).Skip(offset).Take(total);
|
var orderedAccounts = context.Channels.Include(x => x.ClientAccount).Where(x => x.ClientAccount != null).OrderBy(x => x.Id);
|
||||||
return orderedAccounts.ToList();
|
return new ListResultReturn<ChannelEntity>(orderedAccounts.Skip(offset).Take(total).ToList(), orderedAccounts.Count());
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
@@ -41,7 +41,9 @@ public class ClientManager : BackgroundService
|
|||||||
container.Add(cookieColl);
|
container.Add(cookieColl);
|
||||||
}
|
}
|
||||||
|
|
||||||
var ytClient = new YouTubeClient(container, accountEntity.UserAgent ?? "");
|
var ytClient = new YouTubeClient();
|
||||||
|
//ytClient.CookieContainer = container;
|
||||||
|
ytClient.UserAgent = accountEntity.UserAgent;
|
||||||
await ytClient.GetStateAsync();
|
await ytClient.GetStateAsync();
|
||||||
|
|
||||||
return ytClient;
|
return ytClient;
|
||||||
|
9
Manager.YouTube/Models/AdditionalJsonData.cs
Normal file
9
Manager.YouTube/Models/AdditionalJsonData.cs
Normal 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; } = [];
|
||||||
|
}
|
@@ -2,17 +2,35 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace Manager.YouTube.Models.Innertube;
|
namespace Manager.YouTube.Models.Innertube;
|
||||||
|
|
||||||
public class ClientState
|
public class ClientState : AdditionalJsonData
|
||||||
{
|
{
|
||||||
[JsonExtensionData]
|
|
||||||
public Dictionary<string, object> AdditionalData { get; set; } = [];
|
|
||||||
|
|
||||||
[JsonPropertyName("INNERTUBE_API_KEY")]
|
[JsonPropertyName("INNERTUBE_API_KEY")]
|
||||||
public string? InnertubeApiKey { get; set; }
|
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")]
|
[JsonPropertyName("SIGNIN_URL")]
|
||||||
public string? SigninUrl { get; set; }
|
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")]
|
[JsonPropertyName("SBOX_SETTINGS")]
|
||||||
public SBoxSettings? SBoxSettings { get; set; }
|
public SBoxSettings? SBoxSettings { get; set; }
|
||||||
}
|
}
|
15
Manager.YouTube/Models/Innertube/InnerTubeClient.cs
Normal file
15
Manager.YouTube/Models/Innertube/InnerTubeClient.cs
Normal 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; }
|
||||||
|
}
|
9
Manager.YouTube/Models/Innertube/InnerTubeContext.cs
Normal file
9
Manager.YouTube/Models/Innertube/InnerTubeContext.cs
Normal 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; }
|
||||||
|
}
|
@@ -2,28 +2,26 @@ using System.Text.Json;
|
|||||||
using DotBased.Monads;
|
using DotBased.Monads;
|
||||||
using Manager.YouTube.Models.Innertube;
|
using Manager.YouTube.Models.Innertube;
|
||||||
using Manager.YouTube.Parsers;
|
using Manager.YouTube.Parsers;
|
||||||
using Manager.YouTube.Util;
|
|
||||||
|
|
||||||
namespace Manager.YouTube;
|
namespace Manager.YouTube;
|
||||||
|
|
||||||
public static class NetworkService
|
public static class NetworkService
|
||||||
{
|
{
|
||||||
|
private const string Origin = "https://www.youtube.com/";
|
||||||
|
|
||||||
public static async Task<Result<ClientState>> GetClientStateAsync(YouTubeClient client)
|
public static async Task<Result<ClientState>> GetClientStateAsync(YouTubeClient client)
|
||||||
{
|
{
|
||||||
var origin = "https://www.youtube.com/";
|
|
||||||
var httpRequest = new HttpRequestMessage
|
var httpRequest = new HttpRequestMessage
|
||||||
{
|
{
|
||||||
Method = HttpMethod.Get,
|
Method = HttpMethod.Get,
|
||||||
RequestUri = new Uri(origin)
|
RequestUri = new Uri(Origin)
|
||||||
};
|
};
|
||||||
httpRequest.Headers.IfModifiedSince = new DateTimeOffset(DateTime.UtcNow);
|
httpRequest.Headers.IfModifiedSince = new DateTimeOffset(DateTime.UtcNow);
|
||||||
httpRequest.Headers.UserAgent.ParseAdd(client.UserAgent);
|
httpRequest.Headers.UserAgent.ParseAdd(client.UserAgent);
|
||||||
|
httpRequest.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||||
if (client.SapisidCookie != null)
|
httpRequest.Headers.Connection.Add("keep-alive");
|
||||||
{
|
httpRequest.Headers.Add("DNT", "1");
|
||||||
httpRequest.Headers.Authorization = AuthenticationUtilities.GetSapisidHashHeader(client.SapisidCookie.Value, origin);
|
httpRequest.Headers.Add("Upgrade-Insecure-Requests", "1");
|
||||||
httpRequest.Headers.Add("Origin", origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
var http = client.GetHttpClient();
|
var http = client.GetHttpClient();
|
||||||
if (http == null)
|
if (http == null)
|
||||||
@@ -38,7 +36,7 @@ public static class NetworkService
|
|||||||
return Result<ClientState>.Fail(ResultError.Fail(responseResult));
|
return Result<ClientState>.Fail(ResultError.Fail(responseResult));
|
||||||
}
|
}
|
||||||
var responseHtml = await response.Content.ReadAsStringAsync();
|
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 })
|
if (clientStateResult is { IsSuccess: false, Error: not null })
|
||||||
{
|
{
|
||||||
return clientStateResult.Error;
|
return clientStateResult.Error;
|
||||||
@@ -47,7 +45,7 @@ public static class NetworkService
|
|||||||
ClientState? clientState;
|
ClientState? clientState;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
clientState = JsonSerializer.Deserialize<ClientState>(clientStateResult.Value);
|
clientState = JsonSerializer.Deserialize<ClientState>(clientStateResult.Value.Item1);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@@ -56,4 +54,22 @@ public static class NetworkService
|
|||||||
|
|
||||||
return clientState == null ? ResultError.Fail("Unable to parse client state!") : clientState;
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
@@ -1,4 +1,3 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using DotBased.Monads;
|
using DotBased.Monads;
|
||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
@@ -6,34 +5,60 @@ namespace Manager.YouTube.Parsers;
|
|||||||
|
|
||||||
public static class HtmlParser
|
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))
|
if (string.IsNullOrWhiteSpace(html))
|
||||||
{
|
{
|
||||||
return ResultError.Fail("html cannot be empty!");
|
return ResultError.Fail("html cannot be empty!");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(functionName))
|
|
||||||
{
|
|
||||||
return ResultError.Fail("No function names provided!");
|
|
||||||
}
|
|
||||||
|
|
||||||
var htmlDocument = new HtmlDocument();
|
var htmlDocument = new HtmlDocument();
|
||||||
htmlDocument.LoadHtml(html);
|
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 ResultError.Fail($"Could not find {setFunction} in html script nodes!");
|
||||||
return jsonString;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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];
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,33 +1,27 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using DotBased.Logging;
|
|
||||||
using Manager.YouTube.Models.Innertube;
|
using Manager.YouTube.Models.Innertube;
|
||||||
|
|
||||||
namespace Manager.YouTube;
|
namespace Manager.YouTube;
|
||||||
|
|
||||||
public sealed class YouTubeClient : IDisposable
|
public sealed class YouTubeClient : IDisposable
|
||||||
{
|
{
|
||||||
public string Id { get; private set; }
|
public string Id { get; private set; } = "";
|
||||||
public string AccountName { get; private set; }
|
public string AccountName => ClientState?.UserAccountName ?? "";
|
||||||
public string? UserAgent { get; private set; }
|
public string? UserAgent { get; set; }
|
||||||
public CookieContainer CookieContainer { get; }
|
public CookieContainer CookieContainer { get; } = new();
|
||||||
public ClientState? ClientState { get; private set; }
|
public ClientState? ClientState { get; private set; }
|
||||||
public Cookie? SapisidCookie => CookieContainer.GetAllCookies()["SAPISID"];
|
public Cookie? SapisidCookie => CookieContainer.GetAllCookies()["SAPISID"];
|
||||||
public HttpClient? GetHttpClient() => _httpClient;
|
public HttpClient? GetHttpClient() => _httpClient;
|
||||||
|
|
||||||
private readonly ILogger? _logger;
|
|
||||||
private HttpClient? _httpClient;
|
private HttpClient? _httpClient;
|
||||||
|
|
||||||
public YouTubeClient(CookieContainer cookieContainer, string userAgent, ILogger? logger = null)
|
public YouTubeClient()
|
||||||
{
|
{
|
||||||
CookieContainer = cookieContainer;
|
|
||||||
_logger = logger;
|
|
||||||
UserAgent = userAgent;
|
|
||||||
SetupClient();
|
SetupClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetupClient()
|
private void SetupClient()
|
||||||
{
|
{
|
||||||
_logger?.Information("Building http client...");
|
|
||||||
_httpClient?.Dispose();
|
_httpClient?.Dispose();
|
||||||
|
|
||||||
var clientHandler = new HttpClientHandler
|
var clientHandler = new HttpClientHandler
|
||||||
@@ -44,12 +38,10 @@ public sealed class YouTubeClient : IDisposable
|
|||||||
var state = await NetworkService.GetClientStateAsync(this);
|
var state = await NetworkService.GetClientStateAsync(this);
|
||||||
if (!state.IsSuccess)
|
if (!state.IsSuccess)
|
||||||
{
|
{
|
||||||
_logger?.Warning("Error getting client state: {StateError}", state.Error);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientState = state.Value;
|
ClientState = state.Value;
|
||||||
_logger?.Information("Client state retrieved. With API key: {InnertubeApiKey}", ClientState.InnertubeApiKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
Reference in New Issue
Block a user