Compare commits

...

3 Commits

Author SHA1 Message Date
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
14 changed files with 418 additions and 22 deletions

View File

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

View File

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

View File

@@ -5,4 +5,5 @@
<MudNavLink Href="/Library" Icon="@Icons.Material.Filled.LocalLibrary" Match="NavLinkMatch.All">Library</MudNavLink> <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="/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="/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> </MudNavMenu>

View File

@@ -0,0 +1,90 @@
@page "/Services"
@using Manager.App.Services.System
@implements IDisposable
@inject BackgroundServiceManager ServiceManager
<title>Services</title>
<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>
<MudPaper Elevation="0" Class="mt-3" Style="flex: 1; display: flex; flex-direction: column; min-height: 0;">
<MudStack Class="ml-2 mb-2" Spacing="1">
<MudText Typo="Typo.h5">Service events</MudText>
<MudText Typo="Typo.caption">@($"{_serviceEvents.Count}/{VisibleEventCapacity} events")</MudText>
</MudStack>
<div class="console-container">
@foreach (var serviceEvent in _serviceEvents)
{
<div class="log-line">
<span>@serviceEvent.Date</span>
<span>|</span>
<span class="@GetLogClass(serviceEvent)">@serviceEvent.Severity</span>
<span>|</span>
<span style="color: #4d69f1">@serviceEvent.Source</span>
<span>-</span>
<span style="color: #d4d4d4">@serviceEvent.Message</span>
</div>
}
</div>
</MudPaper>
<style>
.console-container {
background-color: #1e1e1e;
color: #9c9898;
padding: 10px;
border-radius: 8px;
flex: 1;
overflow-y: auto;
font-family: monospace;
}
.log-line {
display: flex;
justify-content: start;
align-items: center;
gap: 0.25rem;
}
.log-info {
color: #9cdcfe;
}
.log-warning {
color: #dcdcaa;
}
.log-error {
color: #f44747;
}
</style>

View File

@@ -0,0 +1,67 @@
using DotBased.Logging;
using Manager.App.Extensions;
using Manager.App.Services;
using Microsoft.AspNetCore.Components;
namespace Manager.App.Components.Pages;
public partial class Services : ComponentBase
{
private const int VisibleEventCapacity = 500;
private string _searchText = "";
private List<ExtendedBackgroundService> _backgroundServices = [];
private List<ServiceEvent> _serviceEvents = [];
private CancellationTokenSource _cts = new();
protected override void OnInitialized()
{
_backgroundServices = ServiceManager.GetServices();
_ = Task.Run(() => ReadEventStreamsAsync(_cts.Token));
}
private Func<ExtendedBackgroundService, bool> QuickFilter
=> x => string.IsNullOrWhiteSpace(_searchText) || $"{x.Name} {x.Description} {x.State} {x.ExecuteInterval}".Contains(_searchText);
private async Task ReadEventStreamsAsync(CancellationToken token)
{
if (_backgroundServices.Count == 0)
{
return;
}
var totalToGet = VisibleEventCapacity / _backgroundServices.Count;
_serviceEvents.AddRange(_backgroundServices.SelectMany(x => x.ProgressEvents.Items.TakeLast(totalToGet)));
var asyncEnumerators = _backgroundServices.Select(x => x.ProgressEvents.GetStreamAsync());
await foreach (var serviceEvent in AsyncEnumerableExtensions.Merge(asyncEnumerators, token))
{
if (!_serviceEvents.Contains(serviceEvent))
{
_serviceEvents.Add(serviceEvent);
}
if (_serviceEvents.Count > VisibleEventCapacity)
{
_serviceEvents.RemoveAt(0);
}
await InvokeAsync(StateHasChanged);
}
}
private string GetLogClass(ServiceEvent serviceEvent) =>
serviceEvent.Severity switch
{
LogSeverity.Info => "log-info",
LogSeverity.Warning => "log-warning",
LogSeverity.Error => "log-error",
_ => "log-info"
};
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}

View File

@@ -26,8 +26,8 @@ public static class DependencyInjection
}); });
builder.Services.AddSingleton<HostedServiceConnector>(); builder.Services.AddSingleton<BackgroundServiceManager>();
builder.Services.AddHostedService<ClientManager>(); builder.Services.AddHostedService<ClientService>();
builder.Services.AddScoped<ILibraryService, LibraryService>(); builder.Services.AddScoped<ILibraryService, LibraryService>();
} }

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

@@ -0,0 +1,63 @@
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.CreateUnbounded<T>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false,
});
}
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,106 @@
using DotBased.Logging;
using Manager.App.Services.System;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Manager.App.Services;
public abstract class ExtendedBackgroundService : BackgroundService
{
private TaskCompletionSource _resumeSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly ILogger _logger;
public ServiceState State { get; private set; } = ServiceState.Stopped;
public CircularBuffer<ServiceEvent> ProgressEvents { get; } = new(500);
public string Name { get; }
public string Description { get; set; }
public TimeSpan ExecuteInterval { get; set; }
public ExtendedBackgroundService(string name, string description, ILogger logger, BackgroundServiceManager manager, TimeSpan? executeInterval = null)
{
Name = name;
Description = description;
_logger = logger;
manager.RegisterService(this);
ExecuteInterval = 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 Task.Delay(ExecuteInterval, stoppingToken);
await ExecuteServiceAsync(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(Name, message, DateTime.UtcNow, severity));
public void Pause()
{
if (State == ServiceState.Running)
{
State = ServiceState.Paused;
_logger.LogInformation("Pauses service: {ServiceName}", Name);
}
}
public void Resume()
{
if (State == ServiceState.Paused)
{
State = ServiceState.Running;
_resumeSignal.TrySetResult();
_logger.LogInformation("Resumed service: {ServiceName}", Name);
}
}
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 ServiceEvent(string Source, string Message, DateTime Date, LogSeverity Severity);

View File

@@ -0,0 +1,16 @@
namespace Manager.App.Services.System;
public class BackgroundServiceManager
{
private readonly HashSet<ExtendedBackgroundService> _backgroundServices = [];
public void RegisterService(ExtendedBackgroundService service)
{
_backgroundServices.Add(service);
}
public List<ExtendedBackgroundService> GetServices()
{
return _backgroundServices.ToList();
}
}

View File

@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using DotBased.Logging;
using DotBased.Monads; using DotBased.Monads;
using Manager.App.Models.Library; using Manager.App.Models.Library;
using Manager.Data.Entities.LibraryContext; using Manager.Data.Entities.LibraryContext;
@@ -6,19 +7,28 @@ using Manager.YouTube;
namespace Manager.App.Services.System; namespace Manager.App.Services.System;
public class ClientManager(IServiceScopeFactory scopeFactory, HostedServiceConnector serviceConnector) : BackgroundService public class ClientService(IServiceScopeFactory scopeFactory, ILogger<ClientService> logger, BackgroundServiceManager serviceManager)
: ExtendedBackgroundService("ClientService", "Managing YouTube clients", logger, serviceManager, TimeSpan.FromMilliseconds(100))
{ {
private readonly List<YouTubeClient> _clients = []; private readonly List<YouTubeClient> _clients = [];
private CancellationToken _cancellationToken; private CancellationToken _cancellationToken;
private ILibraryService? _libraryService; private ILibraryService? _libraryService;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task InitializeAsync(CancellationToken stoppingToken)
{ {
serviceConnector.RegisterService(this);
_cancellationToken = stoppingToken; _cancellationToken = stoppingToken;
stoppingToken.Register(CancellationRequested); stoppingToken.Register(CancellationRequested);
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
_libraryService = scope.ServiceProvider.GetRequiredService<ILibraryService>(); _libraryService = scope.ServiceProvider.GetRequiredService<ILibraryService>();
LogEvent("Initializing service...");
Pause();
}
protected override async Task ExecuteServiceAsync(CancellationToken stoppingToken)
{
LogEvent("Sending event...");
LogEvent("Sending warning event...", LogSeverity.Warning);
LogEvent("Sending error event...", LogSeverity.Error);
} }
private void CancellationRequested() private void CancellationRequested()

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": { "Logging": {
"Severity": "Debug", "Severity": "Debug",
"SeverityFilters":{ "SeverityFilters":{
"Microsoft": "Debug", "Microsoft": "Info",
"Microsoft.Hosting.Lifetime": "Debug", "Microsoft.Hosting.Lifetime": "Debug",
"Microsoft.AspNetCore": "Warning", "Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Authentication": "Debug", "Microsoft.AspNetCore.Authentication": "Debug",
"MudBlazor": "Debug" "MudBlazor": "Info"
} }
} }
}, },

View File

@@ -20,7 +20,7 @@ public sealed class YouTubeClient : IDisposable
public List<string> DatasyncIds { get; } = []; public List<string> DatasyncIds { get; } = [];
public Cookie? SapisidCookie => CookieContainer.GetAllCookies()["SAPISID"]; public Cookie? SapisidCookie => CookieContainer.GetAllCookies()["SAPISID"];
public HttpClient HttpClient { get; } public HttpClient HttpClient { get; }
private YouTubeClient(CookieCollection cookies, string userAgent) private YouTubeClient(CookieCollection cookies, string userAgent)
{ {
if (string.IsNullOrWhiteSpace(userAgent)) if (string.IsNullOrWhiteSpace(userAgent))
@@ -38,6 +38,12 @@ public sealed class YouTubeClient : IDisposable
HttpClient = new HttpClient(GetHttpClientHandler()); 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) public static async Task<Result<YouTubeClient>> CreateAsync(CookieCollection cookies, string userAgent)
{ {
var client = new YouTubeClient(cookies, userAgent); var client = new YouTubeClient(cookies, userAgent);