Compare commits

...

14 Commits

Author SHA1 Message Date
max
79ea2badf1 [FIX] EventConsole batch fix 2025-09-18 02:10:13 +02:00
max
5250b9f3f9 [REWORK] Changes saving client and channel info 2025-09-18 02:01:45 +02:00
max
9e173258ed [CHANGES] 2025-09-18 00:42:17 +02:00
max
ab532ac6dc [CHANGE] Fixed cache service && Download channel images from cache 2025-09-18 00:30:35 +02:00
max
8a64d6fc64 [CHANGE] Remove anon accounts && added simple caching for urls 2025-09-17 23:44:02 +02:00
max
0056a14f79 [CHANGE] Fixed auditing, storing images from account import 2025-09-15 00:23:57 +02:00
max
e82736a45f [CHANGE] History -> Audit 2025-09-14 03:30:39 +02:00
max
0f83cf1ddc [CHANGE] Reworked db with interceptors 2025-09-10 23:49:41 +02:00
max
b1e5b0dc68 [CHANGE] event console own component 2025-09-10 20:09:58 +02:00
max
9be6f5be89 [CHANGE] Reworked event console 2025-09-10 18:19:36 +02:00
max
ef6ca0ee07 [OPTIMIZING] 2025-09-10 02:55:06 +02:00
max
03631cd0c8 [CHANGE] Service extended and events 2025-09-10 01:46:07 +02:00
max
9ff4fcded2 [CHANGE] Services view 2025-09-09 19:51:07 +02:00
max
2593d02a73 [CHANGE] BackgroundServices 2025-09-09 19:11:02 +02:00
57 changed files with 1507 additions and 308 deletions

22
.gitignore vendored
View File

@@ -307,10 +307,6 @@ node_modules/
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
@@ -404,14 +400,8 @@ FodyWeavers.xsd
*.sln.iml
.idea
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
@@ -470,16 +460,12 @@ ehthumbs_vista.db
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
/Manager.App/Library/
# Manager.App
[Ll]ibrary/
[Cc]ache/

View File

@@ -2,21 +2,23 @@
<html lang="en">
<head>
<title>Application</title>
<title>YouTube Manager server</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<link rel="stylesheet" href="app.css"/>
<link href="Manager.App.styles.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png"/>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet"/>
<link href="_content/MudBlazor/MudBlazor.min.css?v=@Metadata.Version" rel="stylesheet"/>
<HeadOutlet/>
</head>
<body>
<Routes @rendermode="InteractiveServer"/>
<script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js?v=@Metadata.Version"></script>
<script src="js/tz.js"></script>
<script src="js/eventConsole.js"></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
@inject IJSRuntime JsRuntime
@implements IDisposable
<MudPaper Elevation="Elevation" Class="@Class" Style="@Style">
<MudStack Class="ml-2 mb-2" Spacing="2" Row>
<MudStack Spacing="1">
<MudText Typo="Typo.h5">Live service events</MudText>
<MudText Typo="Typo.caption">@($"{_serviceEvents.Count} events")</MudText>
</MudStack>
<MudSwitch @bind-Value="@_autoScroll">Auto-scroll</MudSwitch>
</MudStack>
<div @ref="@_consoleContainer" class="console-container" @onwheel="OnUserScroll">
<Virtualize @ref="_virtualize" TItem="ServiceEvent" ItemsProvider="VirtualizedItemsProvider" Context="serviceEvent">
<div class="log-line">
@TimeZoneInfo.ConvertTime(serviceEvent.DateUtc, _timeZone)&nbsp;
<span class="log-severity @GetLogClass(serviceEvent)">@serviceEvent.Severity</span>&nbsp;[<span style="color: #1565c0">@serviceEvent.Source</span>]&nbsp;
<span style="color: snow">@serviceEvent.Message</span>
</div>
</Virtualize>
</div>
</MudPaper>

View File

@@ -0,0 +1,165 @@
using DotBased.Logging;
using Manager.App.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.Virtualization;
using Microsoft.JSInterop;
namespace Manager.App.Components.Application.System;
public partial class EventConsole : ComponentBase
{
private const int BatchDelayMs = 2000;
private List<ServiceEvent> _serviceEvents = [];
private readonly List<ServiceEvent> _batchBuffer = [];
private readonly SemaphoreSlim _batchLock = new(1, 1);
private ElementReference _consoleContainer;
private bool _autoScroll = true;
private CancellationTokenSource _cts = new();
private TimeZoneInfo _timeZone = TimeZoneInfo.Local;
private Virtualize<ServiceEvent>? _virtualize;
[Parameter]
public List<ServiceEvent> InitialEvents { get; set; } = [];
[Parameter]
public IAsyncEnumerable<ServiceEvent>? AsyncEnumerable { get; set; }
[Parameter]
public int Elevation { get; set; }
[Parameter]
public string? Class { get; set; }
[Parameter]
public string? Style { get; set; }
protected override async Task OnInitializedAsync()
{
_serviceEvents.AddRange(InitialEvents);
var jsTimeZone = await JsRuntime.InvokeAsync<string>("getUserTimeZone");
if (!string.IsNullOrEmpty(jsTimeZone))
{
_timeZone = TimeZoneInfo.FindSystemTimeZoneById(jsTimeZone);
}
_ = Task.Run(() => ReadEventStreamsAsync(_cts.Token));
}
private async Task ReadEventStreamsAsync(CancellationToken token)
{
if (AsyncEnumerable == null)
{
return;
}
await foreach (var serviceEvent in AsyncEnumerable.WithCancellation(token))
{
await _batchLock.WaitAsync(token);
try
{
_batchBuffer.Add(serviceEvent);
}
finally
{
_batchLock.Release();
}
_ = BatchUpdateUi();
}
}
private string GetLogClass(ServiceEvent serviceEvent) =>
serviceEvent.Severity switch
{
LogSeverity.Info => "log-info",
LogSeverity.Warning => "log-warning",
LogSeverity.Error => "log-error",
LogSeverity.Debug => "log-debug",
LogSeverity.Trace => "log-trace",
LogSeverity.Fatal => "log-fatal",
LogSeverity.Verbose => "log-error",
_ => "log-info"
};
private DateTime _lastBatchUpdate = DateTime.MinValue;
private bool _updateScheduled;
private async Task BatchUpdateUi()
{
if (_updateScheduled) return;
_updateScheduled = true;
while (!_cts.Token.IsCancellationRequested)
{
var elapsed = (DateTime.UtcNow - _lastBatchUpdate).TotalMilliseconds;
if (elapsed < BatchDelayMs)
{
await Task.Delay(BatchDelayMs - (int)elapsed, _cts.Token);
}
List<ServiceEvent> batch;
await _batchLock.WaitAsync();
try
{
if (_batchBuffer.Count == 0) continue;
batch = new List<ServiceEvent>(_batchBuffer);
_batchBuffer.Clear();
}
finally
{
_batchLock.Release();
}
foreach (var serviceEvent in batch.Where(serviceEvent => !_serviceEvents.Contains(serviceEvent)))
{
_serviceEvents.Add(serviceEvent);
}
_lastBatchUpdate = DateTime.UtcNow;
if (_virtualize != null)
{
await _virtualize.RefreshDataAsync();
}
if (_autoScroll)
{
await JsRuntime.InvokeVoidAsync("scrollToBottom", _consoleContainer);
}
await InvokeAsync(StateHasChanged);
_updateScheduled = false;
break;
}
}
private void OnUserScroll(WheelEventArgs e)
{
_ = UpdateAutoScroll();
}
private async Task UpdateAutoScroll()
{
if (_consoleContainer.Context != null)
{
var scrollInfo = await JsRuntime.InvokeAsync<ScrollInfo>("getScrollInfo", _consoleContainer);
_autoScroll = scrollInfo.ScrollTop + scrollInfo.ClientHeight >= scrollInfo.ScrollHeight - 20;
}
}
private ValueTask<ItemsProviderResult<ServiceEvent>> VirtualizedItemsProvider(ItemsProviderRequest request)
{
var items = _serviceEvents.Skip(request.StartIndex).Take(request.Count);
return ValueTask.FromResult(new ItemsProviderResult<ServiceEvent>(items, _serviceEvents.Count));
}
public void Dispose()
{
_batchLock.Dispose();
}
private class ScrollInfo
{
public double ScrollTop { get; set; }
public double ScrollHeight { get; set; }
public double ClientHeight { get; set; }
}
}

View File

@@ -0,0 +1,51 @@
.console-container {
background-color: #1e1e1e;
color: #9c9898;
padding: 10px;
border-radius: 8px;
flex: 1;
overflow-y: auto;
font-family: monospace;
}
.log-severity{
display: inline-block;
width: 8ch;
text-align: left;
font-weight: bold;
}
.log-line {
display: flex;
justify-content: start;
align-items: center;
white-space: nowrap;
}
.log-info {
color: #3f6b81;
}
.log-warning {
color: #f8f802;
}
.log-error {
color: #f44747;
}
.log-debug {
color: #e110ff;
}
.log-trace {
color: #535353;
}
.log-fatal {
color: #af1e1e;
}
.log-verbose {
color: #8085ff;
}

View File

@@ -1,11 +1,13 @@
@using Manager.App.Services.System
@inject ISnackbar SnackbarService
@inject CacheService Cache
<ForcedLoadingOverlay Visible="_isLoading"/>
@{
var client = PreparingClient?.YouTubeClient;
var client = ClientChannel?.YouTubeClient;
var clientState = client?.State;
var channel = PreparingClient?.Channel;
var channel = ClientChannel?.Channel;
var avatar = channel?.AvatarImages.FirstOrDefault();
var banner = channel?.BannerImages.FirstOrDefault();
}
@@ -19,16 +21,15 @@
case AccountImportSteps.Authenticate:
<MudStack Spacing="2">
<MudPaper Elevation="0" Outlined Class="pa-2">
<MudSwitch @bind-Value="@IsAnonymous" Color="Color.Info">Anonymous client</MudSwitch>
<MudTextField @bind-Value="@DefaultUserAgent" Required Label="User agent"
HelperText="Use an WEB client user agent."/>
HelperText="Use an WEB user agent."/>
</MudPaper>
<MudStack Row Spacing="2" Style="height: 100%">
<MudPaper Elevation="0" Outlined Class="pa-2" Style="width: 50%;">
<MudText>Import cookies</MudText>
<MudText Typo="Typo.caption">@($"{ImportCookies.Count} cookie(s) imported")</MudText>
<MudForm @bind-IsValid="@_cookieImportTextValid" Disabled="@(IsAnonymous)">
<MudForm @bind-IsValid="@_cookieImportTextValid">
<MudTextField @bind-Value="@_cookieDomain" Immediate Required Label="Domain"
RequiredError="Domain is required."/>
<MudTextField Class="my-2" Lines="4" AutoGrow @bind-Value="@_cookieText" Immediate
@@ -76,7 +77,7 @@
<MudPaper Elevation="0">
@if (banner != null)
{
<MudImage Src="@banner.Url" Height="250" Style="width: 100%;"/>
<MudImage Src="@Cache.CreateCacheUrl(banner.Url)" Height="250" Style="width: 100%;"/>
}
else
{
@@ -85,7 +86,7 @@
<MudStack Row Spacing="3" Class="px-4">
@if (avatar != null)
{
<MudImage Src="@avatar.Url" Class="mt-n5" Height="100" Width="100"/>
<MudImage Src="@Cache.CreateCacheUrl(avatar.Url)" Class="mt-n5" Height="100" Width="100"/>
}
else
{

View File

@@ -10,8 +10,7 @@ namespace Manager.App.Components.Dialogs
{
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public string DefaultUserAgent { get; set; } = "";
private bool IsAnonymous { get; set; }
private ClientPrep? PreparingClient { get; set; }
private ClientChannel? ClientChannel { get; set; }
private CookieCollection ImportCookies { get; set; } = [];
private bool _isLoading;
private AccountImportSteps _steps = AccountImportSteps.Authenticate;
@@ -22,12 +21,7 @@ namespace Manager.App.Components.Dialogs
private bool CanSave()
{
if (IsAnonymous || PreparingClient?.YouTubeClient?.State?.LoggedIn == true)
{
return true;
}
return false;
return ClientChannel?.YouTubeClient?.State?.LoggedIn == true;
}
private bool CanContinue()
@@ -35,13 +29,13 @@ namespace Manager.App.Components.Dialogs
switch (_steps)
{
case AccountImportSteps.Authenticate:
if (IsAnonymous || ImportCookies.Count != 0)
if (ImportCookies.Count != 0)
{
return true;
}
break;
case AccountImportSteps.Validate:
if (IsAnonymous || PreparingClient?.YouTubeClient?.State?.LoggedIn == true)
if (ClientChannel?.YouTubeClient?.State?.LoggedIn == true)
{
return true;
}
@@ -67,7 +61,7 @@ namespace Manager.App.Components.Dialogs
case AccountImportSteps.Validate:
if (CanSave())
{
MudDialog?.Close(DialogResult.Ok(PreparingClient));
MudDialog?.Close(DialogResult.Ok(ClientChannel));
await InvokeAsync(StateHasChanged);
return;
}
@@ -94,9 +88,8 @@ namespace Manager.App.Components.Dialogs
private void ClearPreparedClient()
{
PreparingClient?.YouTubeClient?.Dispose();
PreparingClient = null;
IsAnonymous = false;
ClientChannel?.YouTubeClient?.Dispose();
ClientChannel = null;
ImportCookies.Clear();
_steps = AccountImportSteps.Authenticate;
StateHasChanged();
@@ -149,28 +142,24 @@ namespace Manager.App.Components.Dialogs
private async Task BuildClient()
{
_isLoading = true;
PreparingClient = new ClientPrep();
if (IsAnonymous)
{
ImportCookies.Clear();
}
ClientChannel = new ClientChannel();
var clientResult = await YouTubeClient.CreateAsync(ImportCookies, DefaultUserAgent);
if (clientResult.IsSuccess)
{
PreparingClient.YouTubeClient = clientResult.Value;
ClientChannel.YouTubeClient = clientResult.Value;
}
if (PreparingClient.YouTubeClient == null)
if (ClientChannel.YouTubeClient == null)
{
SnackbarService.Add("Failed to get client!", Severity.Error);
_isLoading = false;
return;
}
var accountResult = await PreparingClient.YouTubeClient.GetChannelByIdAsync(PreparingClient.YouTubeClient.Id);
var accountResult = await ClientChannel.YouTubeClient.GetChannelByIdAsync(ClientChannel.YouTubeClient.Id);
if (accountResult.IsSuccess)
{
PreparingClient.Channel = accountResult.Value;
ClientChannel.Channel = accountResult.Value;
}
_isLoading = false;
await InvokeAsync(StateHasChanged);

View File

@@ -16,7 +16,7 @@
</MudTooltip>
}
</MudAppBar>
<div style="margin: 20px">
<div style="display: flex; flex-direction: column; flex: 1; padding: 20px; min-height: 0;">
@Body
</div>
</CascadingValue>

View File

@@ -7,7 +7,7 @@
<CascadingValue Value="this">
<MudLayout>
<MudMainContent>
<MudMainContent Style="display: flex; flex-direction: column; height: 100vh;">
@Body
</MudMainContent>
</MudLayout>

View File

@@ -5,4 +5,5 @@
<MudNavLink Href="/Library" Icon="@Icons.Material.Filled.LocalLibrary" Match="NavLinkMatch.All">Library</MudNavLink>
<MudNavLink Href="/Playlists" Icon="@Icons.Material.Filled.ViewList" Match="NavLinkMatch.All">Playlists</MudNavLink>
<MudNavLink Href="/Development" Icon="@Icons.Material.Filled.DeveloperMode" Match="NavLinkMatch.All">Development</MudNavLink>
<MudNavLink Href="/Services" Icon="@Icons.Material.Filled.MiscellaneousServices" Match="NavLinkMatch.All">Services</MudNavLink>
</MudNavMenu>

View File

@@ -6,6 +6,8 @@
@inject ILibraryService LibraryService
@inject IDialogService DialogService
@inject IOptions<LibrarySettings> LibraryOptions
@inject ClientService ClientService
@inject ISnackbar Snackbar
<PageTitle>Channels</PageTitle>
@@ -17,7 +19,7 @@
</MudStack>
</MudPaper>
<MudTable ServerData="ServerReload">
<MudTable @ref="@_table" ServerData="ServerReload">
<ToolBarContent>
<MudText Typo="Typo.h6">Channels</MudText>
</ToolBarContent>

View File

@@ -9,10 +9,11 @@ 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 MudTable<ChannelEntity>? _table;
private async Task<TableData<ChannelEntity>> ServerReload(TableState state, CancellationToken token)
{
var results = await LibraryService.GetChannelAccountsAsync(state.Page * state.PageSize, state.PageSize, token);
var results = await LibraryService.GetChannelsAsync(state.PageSize, state.Page * state.PageSize, token);
return !results.IsSuccess ? new TableData<ChannelEntity>() : new TableData<ChannelEntity> { Items = results.Value, TotalItems = results.Total };
}
@@ -28,22 +29,39 @@ public partial class Channels : ComponentBase
return;
}
var client = (ClientPrep)result.Data;
if (client == null)
var clientChannel = (ClientChannel)result.Data;
if (clientChannel?.YouTubeClient == null)
{
Snackbar.Add("No YouTube client received.", Severity.Error);
return;
}
/*var savedResult = await ClientManager.SaveClientAsync(client);
if (!savedResult.IsSuccess)
var savedClientResult = await ClientService.SaveClientAsync(clientChannel.YouTubeClient);
if (savedClientResult.IsSuccess)
{
Snackbar.Add($"Failed to store client: {savedResult.Error?.Description ?? "Unknown!"}", Severity.Error);
if (_table != null)
{
await _table.ReloadServerData();
}
Snackbar.Add($"Client {clientChannel.Channel?.Handle ?? clientChannel.YouTubeClient.Id} saved!", Severity.Success);
ClientService.AddClient(clientChannel.YouTubeClient);
}
else
{
Snackbar.Add($"Client {client.External.Channel?.Handle ?? client.Id} saved!", Severity.Success);
}*/
Snackbar.Add($"Failed to store client: {savedClientResult.Error?.Description ?? "Unknown!"}", Severity.Error);
}
await InvokeAsync(StateHasChanged);
if (clientChannel.Channel == null)
{
Snackbar.Add("No channel information received!", Severity.Warning);
}
else
{
var saveChannelResult = await LibraryService.SaveChannelAsync(clientChannel.Channel);
if (!saveChannelResult.IsSuccess)
{
Snackbar.Add("Failed to save channel information", Severity.Warning);
}
}
}
}

View File

@@ -1,6 +1,6 @@
@page "/Development"
@using Manager.App.Components.Application.Dev
<title>Development page</title>
<PageTitle>Development page</PageTitle>
<MudTabs Outlined Position="Position.Left" PanelClass="pa-4" ApplyEffectsToContainer Style="height: 100%">
<MudTabPanel Text="Authentication">

View File

@@ -0,0 +1,42 @@
@page "/Services"
@using Manager.App.Services.System
@using Manager.App.Components.Application.System
@implements IDisposable
@inject BackgroundServiceRegistry ServiceRegistry
<PageTitle>Services</PageTitle>
<MudDataGrid T="ExtendedBackgroundService" Items="@_backgroundServices" Filterable QuickFilter="@QuickFilter">
<ToolBarContent>
<MudText Typo="Typo.h6">Services</MudText>
<MudSpacer/>
<MudTextField T="string" @bind-Value="@_searchText" Immediate
Placeholder="Search" Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium"/>
</ToolBarContent>
<Columns>
<PropertyColumn Property="x => x.Name" Title="Service"/>
<PropertyColumn Property="x => x.Description" Title="Description"/>
<PropertyColumn Property="x => x.State" Title="Status"/>
<PropertyColumn Property="x => x.ExecuteInterval" Title="Execute interval"/>
<TemplateColumn>
<CellTemplate>
<MudStack Row Spacing="2">
<MudButton Disabled="@(context.Item?.State == ServiceState.Paused)"
OnClick="@(() => { context.Item?.Pause(); })" Variant="Variant.Outlined">Pause
</MudButton>
<MudButton Disabled="@(context.Item?.State == ServiceState.Running)"
OnClick="@(() => { context.Item?.Resume(); })" Variant="Variant.Outlined">Resume
</MudButton>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
<PagerContent>
<MudDataGridPager T="ExtendedBackgroundService"/>
</PagerContent>
</MudDataGrid>
<EventConsole AsyncEnumerable="@GetEventAsyncEnumerable()" InitialEvents="@GetInitialEvents()"
Elevation="0" Class="mt-3" Style="flex: 1; display: flex; flex-direction: column; min-height: 350px;"/>

View File

@@ -0,0 +1,41 @@
using Manager.App.Extensions;
using Manager.App.Services;
using Microsoft.AspNetCore.Components;
namespace Manager.App.Components.Pages;
public partial class Services : ComponentBase
{
private string _searchText = "";
private List<ExtendedBackgroundService> _backgroundServices = [];
private readonly CancellationTokenSource _cts = new();
protected override void OnInitialized()
{
_backgroundServices = ServiceRegistry.GetServices();
}
private Func<ExtendedBackgroundService, bool> QuickFilter
=> x => string.IsNullOrWhiteSpace(_searchText) || $"{x.Name} {x.Description} {x.State} {x.ExecuteInterval}".Contains(_searchText);
private IAsyncEnumerable<ServiceEvent> GetEventAsyncEnumerable()
{
var asyncEnumerators = _backgroundServices.Select(x => x.ProgressEvents.GetStreamAsync());
return AsyncEnumerableExtensions.Merge(asyncEnumerators, CancellationToken.None);
}
private List<ServiceEvent> GetInitialEvents()
{
var totalToGet = 1000 / _backgroundServices.Count;
var initial = _backgroundServices
.SelectMany(x => x.ProgressEvents.Items.TakeLast(totalToGet))
.OrderBy(x => x.DateUtc);
return initial.ToList();
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}

View File

@@ -3,4 +3,5 @@
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>
</Router>
<HeadOutlet />

View File

@@ -1,6 +1,4 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode

View File

@@ -0,0 +1,18 @@
namespace Manager.App.Constants;
public static class LibraryConstants
{
public static class Directories
{
public const string SubDirMedia = "Media";
public const string SubDirChannels = "Channels";
}
public static class FileTypes
{
public const string ChannelAvatar = "channel/avatar";
public const string ChannelBanner = "channel/banner";
public const string VideoThumbnail = "video/thumbnail";
public const string VideoCaption = "video/caption";
}
}

View File

@@ -0,0 +1,27 @@
using Manager.App.Services.System;
using Microsoft.AspNetCore.Mvc;
namespace Manager.App.Controllers;
[ApiController]
[Route("api/v1/[controller]")]
public class CacheController(ILogger<CacheController> logger, CacheService cacheService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Cache([FromQuery(Name = "url")] string url, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(url))
{
return BadRequest("No url given.");
}
var cacheResult = await cacheService.CacheFromUrl(url, cancellationToken);
if (!cacheResult.IsSuccess)
{
logger.LogError("Cache request failed. {ErrorMessage}", cacheResult.Error?.Description);
return StatusCode(500, cacheResult.Error?.Description);
}
return File(cacheResult.Value.Data, cacheResult.Value.ContentType ?? string.Empty, cacheResult.Value.OriginalFileName);
}
}

View File

@@ -24,10 +24,8 @@ public static class DependencyInjection
logger.LogInformation("Setting library database to: {DbPath}", dbPath);
options.UseSqlite($"Data Source={dbPath}");
});
builder.Services.AddSingleton<HostedServiceConnector>();
builder.Services.AddHostedService<ClientManager>();
builder.RegisterExtendedBackgroundServices();
builder.Services.AddScoped<ILibraryService, LibraryService>();
}
@@ -88,4 +86,20 @@ public static class DependencyInjection
builder.Logging.SetMinimumLevel(isDevelopment ? LogLevel.Trace : LogLevel.Information);
builder.Logging.AddDotBasedLoggerProvider(LogService.Options);
}
private static void RegisterExtendedBackgroundServices(this WebApplicationBuilder builder)
{
var assembly = typeof(Program).Assembly;
foreach (var exBgService in assembly.GetTypes()
.Where(t => typeof(ExtendedBackgroundService).IsAssignableFrom(t)
&& t is { IsClass: true, IsAbstract: false }))
{
builder.Services.AddSingleton(exBgService);
builder.Services.AddSingleton(typeof(ExtendedBackgroundService), sp => (ExtendedBackgroundService)sp.GetRequiredService(exBgService));
builder.Services.AddSingleton<IHostedService>(sp => (IHostedService)sp.GetRequiredService(exBgService));
}
builder.Services.AddSingleton<BackgroundServiceRegistry>();
}
}

View File

@@ -0,0 +1,48 @@
using System.Threading.Channels;
namespace Manager.App.Extensions;
public static class AsyncEnumerableExtensions
{
public static async IAsyncEnumerable<T> Merge<T>(IEnumerable<IAsyncEnumerable<T>> sources, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var channel = Channel.CreateUnbounded<T>( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
var writerTasks = sources.Select(source => Task.Run(async () =>
{
try
{
await foreach (var item in source.WithCancellation(cancellationToken))
{
await channel.Writer.WriteAsync(item, cancellationToken);
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
channel.Writer.TryComplete(ex);
}
}, cancellationToken)).ToArray();
_ = Task.Run(async () =>
{
try
{
await Task.WhenAll(writerTasks);
channel.Writer.TryComplete();
}
catch
{
channel.Writer.TryComplete();
}
}, cancellationToken);
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
{
yield return item;
}
}
}

View File

@@ -22,6 +22,7 @@
<ItemGroup>
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" />
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" />
<_ContentIncludedByDefault Remove="wwwroot\js\console.js" />
</ItemGroup>
<ItemGroup>
@@ -31,6 +32,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="cache\" />
<Folder Include="Library\" />
<Folder Include="Logs\Debug\" />
</ItemGroup>

View File

@@ -1,10 +0,0 @@
using Manager.YouTube;
using Manager.YouTube.Models.Innertube;
namespace Manager.App.Models.Library;
public class ClientPrep
{
public YouTubeClient? YouTubeClient { get; set; }
public Channel? Channel { get; set; }
}

View File

@@ -10,6 +10,8 @@ builder.Services.AddRazorComponents()
AppContext.SetSwitch("System.Net.Http.EnableActivityPropagation", false);
builder.Services.AddControllers();
/* Manager */
builder.SetupLogging();
builder.SetupSettings();
@@ -32,6 +34,7 @@ app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapControllers();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();

View File

@@ -0,0 +1,64 @@
using System.Threading.Channels;
namespace Manager.App.Services;
public class CircularBuffer <T>
{
private readonly T[] _buffer;
private readonly Channel<T> _channel;
private readonly object _lock = new();
public int Capacity { get; }
public int Head { get; private set; }
public int Count { get; private set; }
public CircularBuffer(int capacity)
{
if (capacity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
Capacity = capacity;
_buffer = new T[Capacity];
_channel = Channel.CreateBounded<T>(new BoundedChannelOptions(Capacity)
{
SingleReader = false,
SingleWriter = false,
FullMode = BoundedChannelFullMode.DropOldest
});
}
public void Add(T item)
{
lock (_lock)
{
_buffer[Head] = item;
Head = (Head + 1) % _buffer.Length;
if (Count < _buffer.Length)
{
Count++;
}
}
_channel.Writer.TryWrite(item);
}
public IEnumerable<T> Items
{
get
{
for (var i = 0; i < Count; i++)
{
lock (_lock)
{
yield return _buffer[(Head - Count + i + _buffer.Length) % _buffer.Length];
}
}
}
}
public IAsyncEnumerable<T> GetStreamAsync() => _channel.Reader.ReadAllAsync();
}

View File

@@ -0,0 +1,97 @@
using DotBased.Logging;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Manager.App.Services;
public abstract class ExtendedBackgroundService(string name, string description, ILogger logger, TimeSpan? executeInterval = null)
: BackgroundService
{
private TaskCompletionSource _resumeSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
public ServiceState State { get; private set; } = ServiceState.Stopped;
public CircularBuffer<ServiceEvent> ProgressEvents { get; } = new(500);
public string Name { get; } = name;
public string Description { get; set; } = description;
public TimeSpan ExecuteInterval { get; set; } = executeInterval ?? TimeSpan.FromMinutes(1);
protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken)
{
State = ServiceState.Running;
logger.LogInformation("Initializing background service: {ServiceName}", Name);
await InitializeAsync(stoppingToken);
try
{
logger.LogInformation("Running background service: {ServiceName}", Name);
while (!stoppingToken.IsCancellationRequested)
{
if (State == ServiceState.Paused)
{
_resumeSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await _resumeSignal.Task.WaitAsync(stoppingToken);
}
await ExecuteServiceAsync(stoppingToken);
await Task.Delay(ExecuteInterval, stoppingToken);
}
}
catch (Exception e)
{
if (e is not OperationCanceledException)
{
State = ServiceState.Faulted;
logger.LogError(e,"Background service {ServiceName} faulted!", Name);
throw;
}
logger.LogInformation(e,"Service {ServiceName} received cancellation", Name);
}
finally
{
State = ServiceState.Stopped;
}
}
protected void LogEvent(string message, LogSeverity severity = LogSeverity.Info) => ProgressEvents.Add(new ServiceEvent(string.Intern(Name), message, DateTime.UtcNow, severity));
public void Pause()
{
if (State == ServiceState.Running)
{
State = ServiceState.Paused;
LogEvent("Service paused.");
}
}
public void Resume()
{
if (State == ServiceState.Paused)
{
State = ServiceState.Running;
_resumeSignal.TrySetResult();
LogEvent("Service resumed.");
}
}
protected abstract Task InitializeAsync(CancellationToken stoppingToken);
protected abstract Task ExecuteServiceAsync(CancellationToken stoppingToken);
public override bool Equals(object? obj)
{
return obj is ExtendedBackgroundService bgService && bgService.Name.Equals(Name, StringComparison.OrdinalIgnoreCase);
}
public override int GetHashCode()
{
return Name.GetHashCode();
}
}
public enum ServiceState
{
Stopped,
Faulted,
Running,
Paused
}
public record struct ServiceEvent(string Source, string Message, DateTime DateUtc, LogSeverity Severity);

View File

@@ -2,14 +2,18 @@ using DotBased.Monads;
using Manager.App.Models.Library;
using Manager.App.Models.System;
using Manager.Data.Entities.LibraryContext;
using Manager.YouTube.Models.Innertube;
namespace Manager.App.Services;
public interface ILibraryService
{
public Task<Result> FetchChannelImagesAsync(InnertubeChannel innertubeChannel);
public Task<Result> SaveClientAsync(ClientAccountEntity client, CancellationToken cancellationToken = default);
public Task<Result<ChannelEntity>> GetChannelByIdAsync(string id, CancellationToken cancellationToken = default);
public Task<Result> SaveChannelAsync(ChannelEntity channel, CancellationToken cancellationToken = default);
public Task<Result> SaveChannelAsync(InnertubeChannel innertubeChannel, 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);
public Task<ListResult<ChannelEntity>> GetChannelsAsync(int total = 20, int offset = 0, CancellationToken cancellationToken = default);
}

View File

@@ -1,9 +1,12 @@
using DotBased.Monads;
using Manager.App.Constants;
using Manager.App.Models.Library;
using Manager.App.Models.Settings;
using Manager.App.Models.System;
using Manager.App.Services.System;
using Manager.Data.Contexts;
using Manager.Data.Entities.LibraryContext;
using Manager.YouTube.Models.Innertube;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@@ -15,18 +18,124 @@ public class LibraryService : ILibraryService
private readonly LibrarySettings _librarySettings;
private readonly IDbContextFactory<LibraryDbContext> _dbContextFactory;
private readonly DirectoryInfo _libraryDirectory;
private const string SubDirMedia = "Media";
private const string SubDirChannels = "Channels";
public LibraryService(ILogger<LibraryService> logger, IOptions<LibrarySettings> librarySettings, IDbContextFactory<LibraryDbContext> contextFactory)
private readonly CacheService _cacheService;
public LibraryService(ILogger<LibraryService> logger, IOptions<LibrarySettings> librarySettings, IDbContextFactory<LibraryDbContext> contextFactory, CacheService cacheService)
{
_logger = logger;
_librarySettings = librarySettings.Value;
_dbContextFactory = contextFactory;
_cacheService = cacheService;
_libraryDirectory = Directory.CreateDirectory(_librarySettings.Path);
logger.LogDebug("Working dir for library: {LibraryWorkingDir}", _libraryDirectory.FullName);
Directory.CreateDirectory(Path.Combine(_librarySettings.Path, SubDirMedia));
Directory.CreateDirectory(Path.Combine(_librarySettings.Path, SubDirChannels));
logger.LogDebug("Library directory: {LibraryWorkingDir}", _libraryDirectory.FullName);
Directory.CreateDirectory(Path.Combine(_librarySettings.Path, LibraryConstants.Directories.SubDirMedia));
Directory.CreateDirectory(Path.Combine(_librarySettings.Path, LibraryConstants.Directories.SubDirChannels));
}
public async Task<Result> FetchChannelImagesAsync(InnertubeChannel innertubeChannel)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
await AddWebImagesAsync(context, innertubeChannel.AvatarImages, innertubeChannel.Id, "avatars", LibraryConstants.FileTypes.ChannelAvatar, LibraryConstants.Directories.SubDirChannels);
await AddWebImagesAsync(context, innertubeChannel.BannerImages, innertubeChannel.Id, "banners", LibraryConstants.FileTypes.ChannelBanner, LibraryConstants.Directories.SubDirChannels);
if (!context.ChangeTracker.HasChanges())
{
_logger.LogInformation("No changes detected. Skipping.");
return Result.Success();
}
await context.SaveChangesAsync();
}
catch (Exception e)
{
return ResultError.Error(e);
}
return Result.Success();
}
private async Task AddWebImagesAsync(LibraryDbContext context, List<WebImage> images, string foreignKey, string libSubDir, string fileType, string subDir)
{
foreach (var image in images)
{
if (context.Files.Any(f => image.Url.Equals(f.OriginalUrl, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
var cacheResult = await _cacheService.CacheFromUrl(image.Url);
if (!cacheResult.IsSuccess)
{
_logger.LogWarning("Failed to get image {ImageUrl}", image.Url);
continue;
}
var cachedFile = cacheResult.Value;
var fileId = Guid.NewGuid();
var fileName = cachedFile.OriginalFileName ?? $"{fileId}.{cachedFile.ContentType?.Split('/').Last() ?? "unknown"}";
var relativePath = Path.Combine(foreignKey, libSubDir, $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{fileName}");
var savePath = Path.Combine(_libraryDirectory.FullName, subDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? savePath);
await using var fileStream = File.Create(savePath);
await fileStream.WriteAsync(cachedFile.Data.AsMemory(0, cachedFile.Data.Length));
var file = new FileEntity
{
Id = fileId,
OriginalUrl = image.Url,
OriginalFileName = cachedFile.OriginalFileName,
ForeignKey = foreignKey,
FileType = fileType,
RelativePath = relativePath.Replace('\\', '/'),
MimeType = cachedFile.ContentType,
SizeBytes = cachedFile.Data.Length,
Height = image.Height,
Width = image.Width
};
await context.Files.AddAsync(file);
}
}
public async Task<Result> SaveClientAsync(ClientAccountEntity client, CancellationToken cancellationToken = default)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var updateEntity = false;
var dbClient = context.ClientAccounts.FirstOrDefault(c => c.Id == client.Id);
if (dbClient == null)
{
dbClient = client;
}
else
{
updateEntity = true;
dbClient.HttpCookies = client.HttpCookies;
dbClient.UserAgent = client.UserAgent;
}
if (updateEntity)
{
context.ClientAccounts.Update(dbClient);
}
else
{
context.ClientAccounts.Add(dbClient);
}
var savedResult= await context.SaveChangesAsync(cancellationToken);
return savedResult <= 0 ? ResultError.Fail("Could not save changes!") : Result.Success();
}
catch (Exception e)
{
return ResultError.Error(e);
}
}
public async Task<Result<ChannelEntity>> GetChannelByIdAsync(string id, CancellationToken cancellationToken = default)
@@ -39,7 +148,11 @@ public class LibraryService : ILibraryService
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var channel = await context.Channels.Include(c => c.ClientAccount).FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
var channel = await context.Channels
.Include(c => c.ClientAccount)
.ThenInclude(p => p!.HttpCookies)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
if (channel == null)
{
return ResultError.Fail("Channel not found!");
@@ -53,26 +166,61 @@ public class LibraryService : ILibraryService
}
}
public async Task<Result> SaveChannelAsync(ChannelEntity channel, CancellationToken cancellationToken = default)
public async Task<Result> SaveChannelAsync(InnertubeChannel innertubeChannel, CancellationToken cancellationToken = default)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
if (context.Channels.Any(c => c.Id == channel.Id))
var imagesResult = await FetchChannelImagesAsync(innertubeChannel);
if (!imagesResult.IsSuccess)
{
context.Channels.Update(channel);
return ResultError.Fail("Failed to fetch channel images!");
}
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var channelResult = await GetChannelByIdAsync(innertubeChannel.Id, cancellationToken);
ChannelEntity? channelEntity;
try
{
if (channelResult.IsSuccess)
{
channelEntity = channelResult.Value;
channelEntity.Name = innertubeChannel.ChannelName;
channelEntity.Handle = innertubeChannel.Handle;
channelEntity.Description = innertubeChannel.Description;
}
else
{
channelEntity = new ChannelEntity
{
Id = innertubeChannel.Id,
Name = innertubeChannel.ChannelName,
Handle = innertubeChannel.Handle,
Description = innertubeChannel.Description
};
}
}
catch (Exception e)
{
return ResultError.Error(e);
}
if (context.Channels.Any(c => c.Id == innertubeChannel.Id))
{
context.Channels.Update(channelEntity);
}
else
{
context.Channels.Add(channel);
context.Channels.Add(channelEntity);
}
var changed = await context.SaveChangesAsync(cancellationToken);
return changed <= 0 ? Result.Success() : ResultError.Fail("Failed to save channel!");
return changed <= 0 ? ResultError.Fail("Failed to save channel!") : Result.Success();
}
catch (Exception e)
{
return ResultError.Error(e);
return HandleException(e);
}
}
@@ -98,7 +246,7 @@ public class LibraryService : ILibraryService
}
}
public async Task<ListResult<ChannelEntity>> GetChannelAccountsAsync(int total = 20, int offset = 0, CancellationToken cancellationToken = default)
public async Task<ListResult<ChannelEntity>> GetChannelsAsync(int total = 20, int offset = 0, CancellationToken cancellationToken = default)
{
try
{

View File

@@ -0,0 +1,9 @@
namespace Manager.App.Services.System;
public class BackgroundServiceRegistry(IEnumerable<ExtendedBackgroundService> backgroundServices)
{
public List<ExtendedBackgroundService> GetServices()
{
return backgroundServices.ToList();
}
}

View File

@@ -0,0 +1,184 @@
using System.Security.Cryptography;
using System.Text;
using DotBased.Logging;
using DotBased.Monads;
using Manager.Data.Contexts;
using Manager.Data.Entities.Cache;
using Manager.YouTube;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Manager.App.Services.System;
public class CacheService(ILogger<CacheService> logger, IHostEnvironment environment) : ExtendedBackgroundService(nameof(CacheService), "Manages caching.", logger, TimeSpan.FromHours(5))
{
private DirectoryInfo? _cacheDirectory;
private PooledDbContextFactory<CacheDbContext>? _dbContextFactory;
private const string DataSubDir = "data";
protected override Task InitializeAsync(CancellationToken stoppingToken)
{
_cacheDirectory = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "cache"));
_cacheDirectory.Create();
Directory.CreateDirectory(Path.Combine(_cacheDirectory.FullName, DataSubDir));
LogEvent($"Cache directory: {_cacheDirectory.FullName}");
var dbContextOptionsBuilder = new DbContextOptionsBuilder<CacheDbContext>();
dbContextOptionsBuilder.UseSqlite($"Data Source={Path.Combine(_cacheDirectory.FullName, "cache_index.db")}");
_dbContextFactory = new PooledDbContextFactory<CacheDbContext>(dbContextOptionsBuilder.Options);
return Task.CompletedTask;
}
protected override async Task ExecuteServiceAsync(CancellationToken stoppingToken)
{
if (environment.IsDevelopment())
{
LogEvent("Development mode detected, skipping cache cleaning...");
return;
}
try
{
await ClearCacheAsync(stoppingToken);
}
catch (Exception e)
{
logger.LogError(e, "Error in execution of service.");
LogEvent($"Service execution failed. {e.Message}", LogSeverity.Error);
throw;
}
}
public string CreateCacheUrl(string originalUrl) => $"/api/v1/cache?url={originalUrl}";
public async Task<Result<CacheFile>> CacheFromUrl(string url, CancellationToken cancellationToken = default)
{
try
{
if (string.IsNullOrWhiteSpace(url))
{
return ResultError.Fail("Url is empty.");
}
if (_cacheDirectory == null)
{
return ResultError.Fail("Cache directory is not initialized.");
}
if (_dbContextFactory == null)
{
return ResultError.Fail("Context factory is not initialized.");
}
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var urlKeyBytes = SHA1.HashData(Encoding.UTF8.GetBytes(url));
var urlKey = Convert.ToHexString(urlKeyBytes);
var cacheEntity = await context.Cache.FirstOrDefaultAsync(c => c.Id == urlKey, cancellationToken: cancellationToken);
if (cacheEntity == null)
{
var downloadResult = await NetworkService.DownloadBytesAsync(new HttpRequestMessage(HttpMethod.Get, url));
if (!downloadResult.IsSuccess)
{
LogEvent($"Failed to download from url: {url}");
return ResultError.Fail("Download failed.");
}
var download = downloadResult.Value;
await using var downloadFile = File.Create(Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{urlKey}.cache"));
await downloadFile.WriteAsync(download.Data.AsMemory(0, download.Data.Length), cancellationToken);
cacheEntity = new CacheEntity
{
Id = urlKey,
CachedAtUtc = DateTime.UtcNow,
ContentLength = download.ContentLength,
ContentType = download.ContentType,
OriginalFileName = download.FileName,
};
context.Cache.Add(cacheEntity);
var saved = await context.SaveChangesAsync(cancellationToken);
if (saved <= 0)
{
LogEvent($"Cache entity {cacheEntity.Id} could not be saved.", LogSeverity.Error);
return ResultError.Fail("Failed to save to cache db.");
}
return new CacheFile(download.Data, download.ContentType, download.FileName);
}
var filePath = Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{urlKey}.cache");
var buffer = await File.ReadAllBytesAsync(filePath, cancellationToken);
if (buffer.Length == 0)
{
LogEvent($"Failed to read data from disk. File: {filePath}", LogSeverity.Error);
return ResultError.Fail($"Error reading data from disk. File: {filePath}");
}
return new CacheFile(buffer.ToArray(), cacheEntity.ContentType, cacheEntity.OriginalFileName);
}
catch (Exception e)
{
return ResultError.Error(e, "Cache error.");
}
}
private async Task ClearCacheAsync(CancellationToken cancellationToken)
{
if (_dbContextFactory == null)
{
throw new InvalidOperationException("No DbContext factory configured.");
}
if (_cacheDirectory == null)
{
throw new InvalidOperationException("No cache directory configured.");
}
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var toRemove = dbContext.Cache.Where(c => c.CachedAtUtc < DateTime.UtcNow.AddDays(-1));
if (!toRemove.Any())
{
LogEvent("No items found to purge from cache.");
return;
}
var totalToRemove = toRemove.Count();
LogEvent($"Found {totalToRemove} cache items that are older than 1 day(s)");
var deleted = new List<CacheEntity>();
foreach (var entity in toRemove)
{
var pathToFile = Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{entity.Id}.cache");
if (!File.Exists(pathToFile))
{
deleted.Add(entity);
continue;
}
try
{
File.Delete(pathToFile);
}
catch (Exception e)
{
logger.LogError(e, "Failed to delete cache entity with id: {EntityId}. Skipping cache entity...", entity.Id);
continue;
}
deleted.Add(entity);
}
dbContext.RemoveRange(deleted);
var dbDeleted = await dbContext.SaveChangesAsync(cancellationToken);
if (dbDeleted < deleted.Count)
{
LogEvent("Could not delete all files from cache.", LogSeverity.Warning);
}
LogEvent($"Removed {dbDeleted}/{totalToRemove} items");
}
}
public record CacheFile(byte[] Data, string? ContentType, string? OriginalFileName);

View File

@@ -1,144 +0,0 @@
using System.Net;
using DotBased.Monads;
using Manager.App.Models.Library;
using Manager.Data.Entities.LibraryContext;
using Manager.YouTube;
namespace Manager.App.Services.System;
public class ClientManager(IServiceScopeFactory scopeFactory, HostedServiceConnector serviceConnector) : BackgroundService
{
private readonly List<YouTubeClient> _clients = [];
private CancellationToken _cancellationToken;
private ILibraryService? _libraryService;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
serviceConnector.RegisterService(this);
_cancellationToken = stoppingToken;
stoppingToken.Register(CancellationRequested);
using var scope = scopeFactory.CreateScope();
_libraryService = scope.ServiceProvider.GetRequiredService<ILibraryService>();
}
private void CancellationRequested()
{
// Clear up
}
public async Task<Result<ClientPrep>> PrepareClient()
{
return ResultError.Fail("Not implemented!");
}
/*public async Task<Result> SaveClientAsync(YouTubeClient client, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(client.Id))
{
return ResultError.Fail("Client does not have an ID, cannot save to library database!");
}
var channelResult = await libraryService.GetChannelByIdAsync(client.Id, cancellationToken);
ChannelEntity? channel;
if (channelResult.IsSuccess)
{
channel = channelResult.Value;
UpdateChannelEntity(channel, client);
}
else
{
channel = CreateNewChannelFromClient(client);
}
var saveResult = await libraryService.SaveChannelAsync(channel, cancellationToken);
return saveResult;
}*/
/*private void UpdateChannelEntity(ChannelEntity channel, YouTubeClient client)
{
channel.Name = client.External.Channel?.ChannelName;
channel.Handle = client.External.Channel?.Handle;
channel.Description = client.External.Channel?.Description;
var clientAcc = channel.ClientAccount;
if (clientAcc != null)
{
clientAcc.UserAgent = clientAcc.UserAgent;
var currentCookies = client.CookieContainer.GetAllCookies();
foreach (var cookieEntity in clientAcc.HttpCookies.ToList())
{
var cookie = currentCookies[cookieEntity.Name];
if (cookie == null)
{
clientAcc.HttpCookies.Remove(cookieEntity);
continue;
}
if (!cookie.Domain.Equals(cookieEntity.Domain, StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
cookieEntity.Value = cookie.Value;
cookieEntity.Path = cookie.Path;
cookieEntity.Secure = cookie.Secure;
cookieEntity.HttpOnly = cookie.HttpOnly;
cookieEntity.ExpiresUtc = cookie.Expires == DateTime.MinValue ? null : cookie.Expires;
}
}
}
private ChannelEntity CreateNewChannelFromClient(YouTubeClient client)
{
var cookies = new List<HttpCookieEntity>();
foreach (var cookieObj in client.CookieContainer.GetAllCookies())
{
if (cookieObj is not Cookie cookie)
{
continue;
}
var cookieEntity = new HttpCookieEntity
{
ClientId = client.Id,
Name = cookie.Name,
Value = cookie.Value,
Domain = cookie.Domain,
Path = cookie.Path,
Secure = cookie.Secure,
HttpOnly = cookie.HttpOnly,
ExpiresUtc = cookie.Expires == DateTime.MinValue ? null : cookie.Expires
};
cookies.Add(cookieEntity);
}
var clientAcc = new ClientAccountEntity
{
Id = client.Id,
UserAgent = client.UserAgent,
HttpCookies = cookies
};
var channel = new ChannelEntity
{
Id = client.Id,
Name = client.External.Channel?.ChannelName,
Handle = client.External.Channel?.Handle,
Description = client.External.Channel?.Description,
ClientAccount = clientAcc
};
return channel;
}*/
public async Task<Result<YouTubeClient>> LoadClientByIdAsync(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
return ResultError.Fail("Client ID is empty!");
}
return ResultError.Fail("Not implemented");
}
}

View File

@@ -0,0 +1,112 @@
using System.Net;
using DotBased.Logging;
using DotBased.Monads;
using Manager.Data.Entities.LibraryContext;
using Manager.YouTube;
namespace Manager.App.Services.System;
public class ClientService(IServiceScopeFactory scopeFactory, ILogger<ClientService> logger)
: ExtendedBackgroundService(nameof(ClientService), "Managing YouTube clients", logger, TimeSpan.FromMinutes(10))
{
private readonly YouTubeClientCollection _loadedClients = [];
private ILibraryService? _libraryService;
protected override Task InitializeAsync(CancellationToken stoppingToken)
{
stoppingToken.Register(CancellationRequested);
using var scope = scopeFactory.CreateScope();
_libraryService = scope.ServiceProvider.GetRequiredService<ILibraryService>();
LogEvent("Initializing service...");
return Task.CompletedTask;
}
protected override async Task ExecuteServiceAsync(CancellationToken stoppingToken)
{
LogEvent($"Saving {_loadedClients.Count} loaded client(s)");
foreach (var client in _loadedClients)
{
await SaveClientAsync(client, cancellationToken: stoppingToken);
}
}
private async void CancellationRequested()
{
foreach (var client in _loadedClients)
{
await SaveClientAsync(client);
client.Dispose();
}
}
public async Task<Result> AddClientByIdAsync(string id, CancellationToken stoppingToken = default)
{
if (_libraryService == null)
{
return ResultError.Fail("Library service is not initialized!.");
}
var clientResult = await _libraryService.GetChannelByIdAsync(id, stoppingToken);
if (!clientResult.IsSuccess)
{
return clientResult;
}
var clientAcc = clientResult.Value.ClientAccount;
if (clientAcc == null)
{
return ResultError.Fail("Client account is not initialized!.");
}
var cookieCollection = new CookieCollection();
foreach (var httpCookie in clientAcc.HttpCookies)
{
var cookie = new Cookie
{
Name = httpCookie.Name,
Value = httpCookie.Value,
Domain = httpCookie.Domain,
Path = httpCookie.Path,
Secure = httpCookie.Secure,
HttpOnly = httpCookie.HttpOnly,
Expires = httpCookie.ExpiresUtc ?? DateTime.MinValue
};
cookieCollection.Add(cookie);
}
var ytClientResult = await YouTubeClient.CreateAsync(cookieCollection, clientAcc.UserAgent ?? "");
if (!ytClientResult.IsSuccess)
{
return ytClientResult;
}
AddClient(ytClientResult.Value);
return Result.Success();
}
public void AddClient(YouTubeClient client)
{
if (_loadedClients.Contains(client))
{
return;
}
_loadedClients.Add(client);
}
public async Task<Result> SaveClientAsync(YouTubeClient client, CancellationToken cancellationToken = default)
{
if (_libraryService == null)
{
return ResultError.Fail("Library service is not initialized!.");
}
if (string.IsNullOrWhiteSpace(client.Id))
{
LogEvent("Failed to store client no ID!", LogSeverity.Warning);
return ResultError.Fail("Client does not have an ID, cannot save to library database!");
}
var saveResult = await _libraryService.SaveClientAsync(new ClientAccountEntity { Id = client.Id, UserAgent = client.UserAgent }, cancellationToken);
return saveResult;
}
}

View File

@@ -1,11 +0,0 @@
namespace Manager.App.Services.System;
public class HostedServiceConnector
{
private readonly List<IHostedService> _hostedServices = [];
public void RegisterService(IHostedService service)
{
_hostedServices.Add(service);
}
}

View File

@@ -3,11 +3,11 @@
"Logging": {
"Severity": "Debug",
"SeverityFilters":{
"Microsoft": "Debug",
"Microsoft": "Info",
"Microsoft.Hosting.Lifetime": "Debug",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Authentication": "Debug",
"MudBlazor": "Debug"
"MudBlazor": "Info"
}
}
},

View File

@@ -0,0 +1,16 @@
window.scrollToBottom = (element) => {
if (element) {
requestAnimationFrame(function () {
element.scroll({ top: element.scrollHeight, behavior: 'smooth' });
})
}
};
window.getScrollInfo = (element) => {
if (!element) return null;
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight
};
};

View File

@@ -0,0 +1,3 @@
function getUserTimeZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Manager.Data.Entities.Audit;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Manager.Data.Contexts;
public class AuditInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
AddAudit(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
AddAudit(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void AddAudit(DbContext? context)
{
if (context == null) return;
var entries = context.ChangeTracker.Entries()
.Where(e => e.State is EntityState.Modified or EntityState.Deleted or EntityState.Added && Attribute.IsDefined(e.Entity.GetType(),
typeof(AuditableAttribute)));
var audits = new List<EntityAudit>();
foreach (var entry in entries)
{
var primaryKey = entry.Properties.First(p => p.Metadata.IsPrimaryKey()).CurrentValue?.ToString();
var declaredProperties = entry.Entity.GetType()
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(p => !Attribute.IsDefined(p.DeclaringType!, typeof(NoAuditAttribute), false))
.Select(p => p.Name)
.ToHashSet();
var allowedProperties = entry.Properties.Where(p => declaredProperties.Contains(p.Metadata.Name));
switch (entry.State)
{
case EntityState.Added:
audits.AddRange(allowedProperties
.Where(p => p.CurrentValue != null)
.Select(p => CreateAudit(entry, p, entry.State, primaryKey))
);
break;
case EntityState.Modified:
audits.AddRange(allowedProperties
.Where(p => p.IsModified)
.Select(p => CreateAudit(entry, p, entry.State, primaryKey))
);
break;
case EntityState.Deleted:
audits.AddRange(allowedProperties
.Select(p => CreateAudit(entry, p, entry.State, primaryKey))
);
break;
}
}
if (audits.Count != 0)
{
context.Set<EntityAudit>().AddRange(audits);
}
}
private EntityAudit CreateAudit(EntityEntry entry, PropertyEntry prop, EntityState changeType, string? primaryKey)
{
return new EntityAudit
{
Id = Guid.NewGuid(),
EntityName = entry.Entity.GetType().Name,
EntityId = primaryKey ?? "Unknown",
PropertyName = prop.Metadata.Name,
OldValue = SerializeValue(prop.OriginalValue),
NewValue = SerializeValue(prop.CurrentValue),
ModifiedUtc = DateTime.UtcNow,
ChangedBy = "SYSTEM",
ChangeType = changeType
};
}
private readonly JsonSerializerOptions _jsonSerializerOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private string? SerializeValue(object? value)
{
if (value == null) return null;
var type = value.GetType();
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime) || type == typeof(decimal))
{
return value.ToString();
}
return JsonSerializer.Serialize(value, _jsonSerializerOptions);
}
}

View File

@@ -0,0 +1,25 @@
using Manager.Data.Entities.Cache;
using Microsoft.EntityFrameworkCore;
namespace Manager.Data.Contexts;
public sealed class CacheDbContext : DbContext
{
public CacheDbContext(DbContextOptions<CacheDbContext> options) : base(options)
{
Database.EnsureCreated();
}
public DbSet<CacheEntity> Cache { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CacheEntity>(ce =>
{
ce.ToTable("cache");
ce.HasKey(x => x.Id);
});
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,38 @@
using Manager.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Manager.Data.Contexts;
public class DateInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
UpdateEntryDates(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = new())
{
UpdateEntryDates(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void UpdateEntryDates(DbContext? context)
{
if (context == null) return;
var entries = context.ChangeTracker.Entries().Where(x => x is { Entity: DateTimeBase, State: EntityState.Added or EntityState.Modified });
foreach (var entity in entries)
{
((DateTimeBase)entity.Entity).LastModifiedUtc = DateTime.UtcNow;
if (entity.State == EntityState.Added)
{
((DateTimeBase)entity.Entity).CreatedAtUtc = DateTime.UtcNow;
}
}
}
}

View File

@@ -1,4 +1,4 @@
using Manager.Data.Entities;
using Manager.Data.Entities.Audit;
using Manager.Data.Entities.LibraryContext;
using Manager.Data.Entities.LibraryContext.Join;
using Microsoft.EntityFrameworkCore;
@@ -13,17 +13,31 @@ public sealed class LibraryDbContext : DbContext
ChangeTracker.LazyLoadingEnabled = false;
Database.EnsureCreated();
}
public DbSet<EntityAudit> Histories { get; set; }
public DbSet<CaptionEntity> Captions { get; set; }
public DbSet<ChannelEntity> Channels { get; set; }
public DbSet<ClientAccountEntity> Accounts { get; set; }
public DbSet<ClientAccountEntity> ClientAccounts { get; set; }
public DbSet<HttpCookieEntity> HttpCookies { get; set; }
public DbSet<MediaEntity> Media { get; set; }
public DbSet<MediaFormatEntity> MediaFormats { get; set; }
public DbSet<PlaylistEntity> Playlists { get; set; }
public DbSet<FileEntity> Files { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(new DateInterceptor(), new AuditInterceptor());
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EntityAudit>(ea =>
{
ea.HasKey(a => a.Id);
ea.ToTable("audits");
});
modelBuilder.Entity<CaptionEntity>(ce =>
{
ce.ToTable("captions");
@@ -83,6 +97,12 @@ public sealed class LibraryDbContext : DbContext
ple.ToTable("playlists");
ple.HasKey(x => x.Id);
});
modelBuilder.Entity<FileEntity>(file =>
{
file.ToTable("files");
file.HasKey(x => x.Id);
});
/* Join tables */
@@ -101,31 +121,4 @@ public sealed class LibraryDbContext : DbContext
base.OnModelCreating(modelBuilder);
}
public override int SaveChanges()
{
UpdateEntryDates();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = new())
{
UpdateEntryDates();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void UpdateEntryDates()
{
var entries = ChangeTracker.Entries().Where(x => x is { Entity: DateTimeBase, State: EntityState.Added or EntityState.Modified });
foreach (var entity in entries)
{
((DateTimeBase)entity.Entity).LastModifiedUtc = DateTime.UtcNow;
if (entity.State == EntityState.Added)
{
((DateTimeBase)entity.Entity).CreatedAtUtc = DateTime.UtcNow;
}
}
}
}

View File

@@ -4,7 +4,8 @@ public static class DataConstants
{
public static class DbContext
{
public const int DefaultDbStringSize = 100;
public const int DefaultDbDescriptionStringSize = 500;
public const int DefaultDbStringSize = 500;
public const int DefaultDbDescriptionStringSize = 5500;
public const int DefaultDbUrlSize = 10000;
}
}

View File

@@ -0,0 +1,9 @@
namespace Manager.Data.Entities.Audit;
/// <summary>
/// Make all properties in the entity audible, if they are changed this will be stored as a history in the db.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class AuditableAttribute : Attribute
{
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace Manager.Data.Entities.Audit;
public class EntityAudit
{
public required Guid Id { get; set; }
[MaxLength(200)]
public required string EntityName { get; set; }
[MaxLength(200)]
public required string EntityId { get; set; }
[MaxLength(200)]
public required string PropertyName { get; set; }
[MaxLength(1000)]
public string? OldValue { get; set; }
[MaxLength(1000)]
public string? NewValue { get; set; }
public DateTime ModifiedUtc { get; set; } = DateTime.UtcNow;
[MaxLength(200)]
public string? ChangedBy { get; set; }
public EntityState ChangeType { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Manager.Data.Entities.Audit;
/// <summary>
/// Specifies to ignore the properties in the entity to not audit.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class NoAuditAttribute : Attribute
{
}

View File

@@ -1,7 +1,10 @@
using Manager.Data.Entities.Audit;
namespace Manager.Data.Entities;
[NoAudit]
public abstract class DateTimeBase
{
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime LastModifiedUtc { get; set; } = DateTime.UtcNow;
public DateTime CreatedAtUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View File

@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using Manager.Data.Entities.Audit;
namespace Manager.Data.Entities.LibraryContext;
[Auditable]
public class CaptionEntity : DateTimeBase
{
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]

View File

@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using Manager.Data.Entities.Audit;
namespace Manager.Data.Entities.LibraryContext;
[Auditable]
public class ChannelEntity : DateTimeBase
{
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]

View File

@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using Manager.Data.Entities.Audit;
namespace Manager.Data.Entities.LibraryContext;
[Auditable]
public class ClientAccountEntity : DateTimeBase
{
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]

View File

@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace Manager.Data.Entities.LibraryContext;
public class FileEntity : DateTimeBase
{
public required Guid Id { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public required string ForeignKey { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public required string FileType { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public required string RelativePath { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public string? MimeType { get; set; }
public long SizeBytes { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbUrlSize)]
public string? OriginalUrl { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public string? OriginalFileName { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public long? LenghtMilliseconds { get; set; }
}

View File

@@ -1,9 +1,13 @@
using System.ComponentModel.DataAnnotations;
using Manager.Data.Entities.Audit;
namespace Manager.Data.Entities.LibraryContext;
[NoAudit]
public class HttpCookieEntity : DateTimeBase
{
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public required string ClientId { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public required string Name { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
@@ -12,9 +16,7 @@ public class HttpCookieEntity : DateTimeBase
public string? Domain { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public string? Path { get; set; }
public DateTimeOffset? ExpiresUtc { get; set; }
public DateTime? ExpiresUtc { get; set; }
public bool Secure { get; set; }
public bool HttpOnly { get; set; }
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
public required string ClientId { get; set; }
}

View File

@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations;
using Manager.Data.Entities.Audit;
using Manager.Data.Entities.LibraryContext.Join;
namespace Manager.Data.Entities.LibraryContext;
[Auditable]
public class MediaEntity : DateTimeBase
{
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]
@@ -17,7 +19,6 @@ public class MediaEntity : DateTimeBase
public List<MediaFormatEntity> Formats { get; set; } = [];
public List<CaptionEntity> Captions { get; set; } = [];
public List<PlaylistMedia> PlaylistMedias { get; set; } = [];
public MediaExternalState ExternalState { get; set; } = MediaExternalState.Online;
public bool IsDownloaded { get; set; }
public MediaState State { get; set; } = MediaState.Indexed;

View File

@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations;
using Manager.Data.Entities.Audit;
using Manager.Data.Entities.LibraryContext.Join;
namespace Manager.Data.Entities.LibraryContext;
[Auditable]
public class PlaylistEntity : DateTimeBase
{
[MaxLength(DataConstants.DbContext.DefaultDbStringSize)]

View File

@@ -1,7 +1,8 @@
namespace Manager.YouTube.Models.Innertube;
public class Channel
public class InnertubeChannel
{
public required string Id { get; set; }
public bool NoIndex { get; set; }
public bool Unlisted { get; set; }
public bool FamilySafe { get; set; }

View File

@@ -6,6 +6,7 @@ namespace Manager.YouTube;
public static class NetworkService
{
public const string Origin = "https://www.youtube.com";
private static readonly HttpClient HttpClient = new();
public static async Task<Result<string>> MakeRequestAsync(HttpRequestMessage request, YouTubeClient client, bool skipAuthenticationHeader = false)
{
@@ -31,4 +32,26 @@ public static class NetworkService
return ResultError.Error(e);
}
}
}
public static async Task<Result<DownloadResult>> DownloadBytesAsync(HttpRequestMessage request, YouTubeClient? client = null)
{
try
{
var response = client != null ? await client.HttpClient.SendAsync(request) : await HttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return ResultError.Fail($"Failed to get file to download, response code: {response.StatusCode}.");
}
var data = await response.Content.ReadAsByteArrayAsync();
return new DownloadResult(data, response.Content.Headers.ContentType?.MediaType, response.Content.Headers.ContentDisposition?.FileName?.Trim('"'), response.Content.Headers.ContentLength ?? 0);
}
catch (Exception e)
{
return ResultError.Error(e);
}
}
}
public record DownloadResult(byte[] Data, string? ContentType, string? FileName, long ContentLength);

View File

@@ -4,18 +4,30 @@ using Manager.YouTube.Models.Innertube;
namespace Manager.YouTube.Parsers.Json;
/// <summary>
/// Parsing functionality for the response from the innertube browse endpoint.
/// </summary>
public static class ChannelJsonParser
{
public static Result<Channel> ParseJsonToChannelData(string json)
public static Result<InnertubeChannel> ParseJsonToChannelData(string json)
{
try
{
var channel = new Channel();
var doc = JsonDocument.Parse(json);
var rootDoc = doc.RootElement;
var channelMetadata = rootDoc
.GetProperty("metadata")
.GetProperty("channelMetadataRenderer");
var channelId = channelMetadata.GetProperty("externalId").GetString();
if (channelId == null)
{
throw new InvalidOperationException("No channel id found.");
}
var channel = new InnertubeChannel
{
Id = channelId,
ChannelName = channelMetadata.GetProperty("title").ToString(),
};
var microformat = rootDoc.GetProperty("microformat").GetProperty("microformatDataRenderer");
@@ -29,11 +41,6 @@ public static class ChannelJsonParser
channel.Unlisted = microformat.GetProperty("unlisted").GetBoolean();
channel.FamilySafe = microformat.GetProperty("familySafe").GetBoolean();
var channelMetadata = rootDoc
.GetProperty("metadata")
.GetProperty("channelMetadataRenderer");
channel.ChannelName = channelMetadata.GetProperty("title").GetString();
var avatarThumbnails = channelMetadata.GetProperty("avatar")
.GetProperty("thumbnails")
.EnumerateArray();

View File

@@ -20,7 +20,7 @@ public sealed class YouTubeClient : IDisposable
public List<string> DatasyncIds { get; } = [];
public Cookie? SapisidCookie => CookieContainer.GetAllCookies()["SAPISID"];
public HttpClient HttpClient { get; }
private YouTubeClient(CookieCollection cookies, string userAgent)
{
if (string.IsNullOrWhiteSpace(userAgent))
@@ -38,6 +38,12 @@ public sealed class YouTubeClient : IDisposable
HttpClient = new HttpClient(GetHttpClientHandler());
}
/// <summary>
/// Loads the given cookies and fetch client state.
/// </summary>
/// <param name="cookies">The cookies to use for making requests. Empty collection for anonymous requests.</param>
/// <param name="userAgent">The user agent to use for the requests. Only WEB client is supported.</param>
/// <returns></returns>
public static async Task<Result<YouTubeClient>> CreateAsync(CookieCollection cookies, string userAgent)
{
var client = new YouTubeClient(cookies, userAgent);
@@ -142,7 +148,7 @@ public sealed class YouTubeClient : IDisposable
return Result.Success();
}
public async Task<Result<Channel>> GetChannelByIdAsync(string channelId)
public async Task<Result<InnertubeChannel>> GetChannelByIdAsync(string channelId)
{
if (State == null)
{
@@ -198,7 +204,7 @@ public sealed class YouTubeClient : IDisposable
public void Dispose()
{
HttpClient?.Dispose();
HttpClient.Dispose();
}
private async Task<Result<string>> GetCurrentAccountIdAsync()

View File

@@ -0,0 +1,11 @@
using System.Collections.ObjectModel;
namespace Manager.YouTube;
public class YouTubeClientCollection : KeyedCollection<string, YouTubeClient>
{
protected override string GetKeyForItem(YouTubeClient item)
{
return item.Id;
}
}