Browse Source

show database and search index initialization in ui (#1325)

* unblock startup, show database initialization message

* wait on search index to be ready (rebuild)

* clean logging and fake delay
pull/1326/head
Jason Dove 2 years ago committed by GitHub
parent
commit
8277894f7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs
  3. 33
      ErsatzTV.Core/SystemStartup.cs
  4. 183
      ErsatzTV/Pages/Index.razor
  5. 11
      ErsatzTV/Services/EmbyService.cs
  6. 20
      ErsatzTV/Services/FFmpegLocatorService.cs
  7. 2
      ErsatzTV/Services/FFmpegWorkerService.cs
  8. 11
      ErsatzTV/Services/JellyfinService.cs
  9. 11
      ErsatzTV/Services/PlexService.cs
  10. 17
      ErsatzTV/Services/RunOnce/CacheCleanerService.cs
  11. 16
      ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs
  12. 10
      ErsatzTV/Services/RunOnce/EndpointValidatorService.cs
  13. 21
      ErsatzTV/Services/RunOnce/LoadLoggingLevelService.cs
  14. 11
      ErsatzTV/Services/RunOnce/PlatformSettingsService.cs
  15. 21
      ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs
  16. 8
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  17. 2
      ErsatzTV/Services/ScannerService.cs
  18. 75
      ErsatzTV/Services/SchedulerService.cs
  19. 2
      ErsatzTV/Services/SearchIndexService.cs
  20. 2
      ErsatzTV/Services/WorkerService.cs
  21. 194
      ErsatzTV/Shared/MainLayout.razor
  22. 1
      ErsatzTV/Startup.cs

6
CHANGELOG.md

@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Only allow a single instance of ErsatzTV to run
- This fixes some cases where the search index would become unusable
### Changed
- Rework startup process to show UI as early as possible
- A minimal UI will indicate when the database and search index are initializing
- The UI will automatically refresh when the initialization processes have completed
## [0.8.0-beta] - 2023-06-23
### Added
- Disable playout buttons and show spinning indicator when a playout is being modified (built/extended, or subtitles are being extracted)

5
ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs

@ -14,6 +14,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex> @@ -14,6 +14,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly SystemStartup _systemStartup;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex;
@ -25,6 +26,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex> @@ -25,6 +26,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
IFallbackMetadataProvider fallbackMetadataProvider,
SystemStartup systemStartup,
ILogger<RebuildSearchIndexHandler> logger)
{
_searchIndex = searchIndex;
@ -33,6 +35,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex> @@ -33,6 +35,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_fallbackMetadataProvider = fallbackMetadataProvider;
_systemStartup = systemStartup;
}
public async Task Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
@ -63,5 +66,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex> @@ -63,5 +66,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
{
_logger.LogInformation("Search index is already version {Version}", _searchIndex.Version);
}
_systemStartup.SearchIndexIsReady();
}
}

33
ErsatzTV.Core/SystemStartup.cs

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
namespace ErsatzTV.Core;
public class SystemStartup
{
private readonly SemaphoreSlim _databaseStartup = new(0, 100);
private readonly SemaphoreSlim _searchIndexStartup = new(0, 100);
public event EventHandler OnDatabaseReady;
public event EventHandler OnSearchIndexReady;
public bool IsDatabaseReady { get; private set; }
public bool IsSearchIndexReady { get; private set; }
public async Task WaitForDatabase(CancellationToken cancellationToken) =>
await _databaseStartup.WaitAsync(cancellationToken);
public async Task WaitForSearchIndex(CancellationToken cancellationToken) =>
await _searchIndexStartup.WaitAsync(cancellationToken);
public void DatabaseIsReady()
{
_databaseStartup.Release(100);
IsDatabaseReady = true;
OnDatabaseReady?.Invoke(this, EventArgs.Empty);
}
public void SearchIndexIsReady()
{
_searchIndexStartup.Release(100);
IsSearchIndexReady = true;
OnSearchIndexReady?.Invoke(this, EventArgs.Empty);
}
}

183
ErsatzTV/Pages/Index.razor

@ -5,70 +5,107 @@ @@ -5,70 +5,107 @@
@using ErsatzTV.Core.Interfaces.GitHub
@using ErsatzTV.Application.Health
@implements IDisposable
@inject IGitHubApiClient _gitHubApiClient
@inject IMemoryCache _memoryCache
@inject IMediator _mediator
@inject IGitHubApiClient GitHubApiClient
@inject IMemoryCache MemoryCache
@inject IMediator Mediator
@inject SystemStartup SystemStartup;
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudCard>
<MudCardContent Class="release-notes mud-typography mud-typography-body1">
<MarkdownView Content="@_releaseNotes"/>
</MudCardContent>
</MudCard>
<MudText Class="mt-6">Full changelog is available on <MudLink Href="https://github.com/jasongdove/ErsatzTV/blob/main/CHANGELOG.md">GitHub</MudLink></MudText>
<MudTable Class="mt-6"
Hover="true"
Dense="true"
ServerData="@(new Func<TableState, Task<TableData<HealthCheckResult>>>(ServerReload))"
@ref="_table">
<ToolBarContent>
<MudText Typo="Typo.h6">Health Checks</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Check</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Check">
<div style="align-items: center; display: flex; flex-direction: row;">
@if (context.Status == HealthCheckStatus.Fail)
{
<MudIcon Color="@Color.Error" Icon="@Icons.Material.Filled.Error"/>
}
else if (context.Status == HealthCheckStatus.Warning)
{
<MudIcon Color="@Color.Warning" Icon="@Icons.Material.Filled.Warning"/>
}
else if (context.Status == HealthCheckStatus.Info)
@if (!SystemStartup.IsDatabaseReady)
{
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h4">Database is initializing</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText>Please wait, this may take a few minutes.</MudText>
<MudText>This page will automatically refresh when the database is ready.</MudText>
</MudCardContent>
<MudCardActions>
<MudProgressCircular Color="Color.Primary" Size="Size.Medium" Indeterminate="true"/>
</MudCardActions>
</MudCard>
}
else if (!SystemStartup.IsSearchIndexReady)
{
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h4">Search Index is initializing</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText>Please wait, this may take a few minutes.</MudText>
<MudText>This page will automatically refresh when the search index is ready.</MudText>
</MudCardContent>
<MudCardActions>
<MudProgressCircular Color="Color.Primary" Size="Size.Medium" Indeterminate="true"/>
</MudCardActions>
</MudCard>
}
else
{
<MudCard>
<MudCardContent Class="release-notes mud-typography mud-typography-body1">
<MarkdownView Content="@_releaseNotes"/>
</MudCardContent>
</MudCard>
<MudText Class="mt-6">Full changelog is available on <MudLink Href="https://github.com/jasongdove/ErsatzTV/blob/main/CHANGELOG.md">GitHub</MudLink></MudText>
<MudTable Class="mt-6"
Hover="true"
Dense="true"
ServerData="@(new Func<TableState, Task<TableData<HealthCheckResult>>>(ServerReload))"
@ref="_table">
<ToolBarContent>
<MudText Typo="Typo.h6">Health Checks</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Check</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Check">
<div style="align-items: center; display: flex; flex-direction: row;">
@if (context.Status == HealthCheckStatus.Fail)
{
<MudIcon Color="@Color.Error" Icon="@Icons.Material.Filled.Error"/>
}
else if (context.Status == HealthCheckStatus.Warning)
{
<MudIcon Color="@Color.Warning" Icon="@Icons.Material.Filled.Warning"/>
}
else if (context.Status == HealthCheckStatus.Info)
{
<MudIcon Color="@Color.Info" Icon="@Icons.Material.Filled.Info"/>
}
else
{
<MudIcon Color="@Color.Success" Icon="@Icons.Material.Filled.Check"/>
}
<div class="ml-2">@context.Title</div>
</div>
</MudTd>
<MudTd DataLabel="Message">
@if (context.Link.IsSome)
{
<MudIcon Color="@Color.Info" Icon="@Icons.Material.Filled.Info"/>
foreach (string link in context.Link)
{
<MudLink Href="@link">
@context.Message
</MudLink>
}
}
else
{
<MudIcon Color="@Color.Success" Icon="@Icons.Material.Filled.Check"/>
}
<div class="ml-2">@context.Title</div>
</div>
</MudTd>
<MudTd DataLabel="Message">
@if (context.Link.IsSome)
{
foreach (string link in context.Link)
{
<MudLink Href="@link">
<MudText>
@context.Message
</MudLink>
</MudText>
}
}
else
{
<MudText>
@context.Message
</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
</MudTd>
</RowTemplate>
</MudTable> }
</MudContainer>
@code {
@ -77,17 +114,31 @@ @@ -77,17 +114,31 @@
private string _releaseNotes;
private MudTable<HealthCheckResult> _table;
protected override void OnInitialized()
{
SystemStartup.OnDatabaseReady += OnStartupProgress;
SystemStartup.OnSearchIndexReady += OnStartupProgress;
}
public void Dispose()
{
SystemStartup.OnDatabaseReady -= OnStartupProgress;
SystemStartup.OnSearchIndexReady -= OnStartupProgress;
_cts.Cancel();
_cts.Dispose();
}
private async void OnStartupProgress(object sender, EventArgs e)
{
await InvokeAsync(StateHasChanged);
}
protected override async Task OnParametersSetAsync()
{
try
{
if (_memoryCache.TryGetValue("Index.ReleaseNotesHtml", out string releaseNotesHtml))
if (MemoryCache.TryGetValue("Index.ReleaseNotesHtml", out string releaseNotesHtml))
{
_releaseNotes = releaseNotesHtml;
}
@ -109,20 +160,26 @@ @@ -109,20 +160,26 @@
gitHubVersion = $"v{gitHubVersion}";
}
maybeNotes = await _gitHubApiClient.GetReleaseNotes(gitHubVersion, _cts.Token);
maybeNotes.IfRight(notes => _releaseNotes = notes);
maybeNotes = await GitHubApiClient.GetReleaseNotes(gitHubVersion, _cts.Token);
foreach (string notes in maybeNotes.RightToSeq())
{
_releaseNotes = notes;
}
}
else
{
maybeNotes = await _gitHubApiClient.GetLatestReleaseNotes(_cts.Token);
maybeNotes.IfRight(notes => _releaseNotes = notes);
maybeNotes = await GitHubApiClient.GetLatestReleaseNotes(_cts.Token);
foreach (string notes in maybeNotes.RightToSeq())
{
_releaseNotes = notes;
}
}
}
}
if (_releaseNotes != null)
{
_memoryCache.Set("Index.ReleaseNotesHtml", _releaseNotes);
MemoryCache.Set("Index.ReleaseNotesHtml", _releaseNotes);
}
}
}
@ -134,7 +191,7 @@ @@ -134,7 +191,7 @@
private async Task<TableData<HealthCheckResult>> ServerReload(TableState state)
{
List<HealthCheckResult> healthCheckResults = await _mediator.Send(new GetAllHealthCheckResults(), _cts.Token);
List<HealthCheckResult> healthCheckResults = await Mediator.Send(new GetAllHealthCheckResults(), _cts.Token);
return new TableData<HealthCheckResult>
{

11
ErsatzTV/Services/EmbyService.cs

@ -13,19 +13,30 @@ public class EmbyService : BackgroundService @@ -13,19 +13,30 @@ public class EmbyService : BackgroundService
private readonly ChannelReader<IEmbyBackgroundServiceRequest> _channel;
private readonly ILogger<EmbyService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public EmbyService(
ChannelReader<IEmbyBackgroundServiceRequest> channel,
IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<EmbyService> logger)
{
_channel = channel;
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
await _systemStartup.WaitForDatabase(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
try
{
if (!File.Exists(FileSystemLayout.EmbySecretsPath))

20
ErsatzTV/Services/FFmpegLocatorService.cs

@ -1,23 +1,35 @@ @@ -1,23 +1,35 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Services;
public class FFmpegLocatorService : IHostedService
public class FFmpegLocatorService : BackgroundService
{
private readonly ILogger<FFmpegLocatorService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public FFmpegLocatorService(
IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<FFmpegLocatorService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
await _systemStartup.WaitForDatabase(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IFFmpegLocator ffmpegLocator = scope.ServiceProvider.GetRequiredService<IFFmpegLocator>();
@ -34,6 +46,4 @@ public class FFmpegLocatorService : IHostedService @@ -34,6 +46,4 @@ public class FFmpegLocatorService : IHostedService
path => _logger.LogInformation("Located ffprobe at {Path}", path),
() => _logger.LogWarning("Failed to locate ffprobe executable"));
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

2
ErsatzTV/Services/FFmpegWorkerService.cs

@ -27,6 +27,8 @@ public class FFmpegWorkerService : BackgroundService @@ -27,6 +27,8 @@ public class FFmpegWorkerService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
try
{
_logger.LogInformation("FFmpeg worker service started");

11
ErsatzTV/Services/JellyfinService.cs

@ -13,19 +13,30 @@ public class JellyfinService : BackgroundService @@ -13,19 +13,30 @@ public class JellyfinService : BackgroundService
private readonly ChannelReader<IJellyfinBackgroundServiceRequest> _channel;
private readonly ILogger<JellyfinService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public JellyfinService(
ChannelReader<IJellyfinBackgroundServiceRequest> channel,
IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<JellyfinService> logger)
{
_channel = channel;
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
await _systemStartup.WaitForDatabase(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
try
{
if (!File.Exists(FileSystemLayout.JellyfinSecretsPath))

11
ErsatzTV/Services/PlexService.cs

@ -13,19 +13,30 @@ public class PlexService : BackgroundService @@ -13,19 +13,30 @@ public class PlexService : BackgroundService
private readonly ChannelReader<IPlexBackgroundServiceRequest> _channel;
private readonly ILogger<PlexService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public PlexService(
ChannelReader<IPlexBackgroundServiceRequest> channel,
IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<PlexService> logger)
{
_channel = channel;
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
await _systemStartup.WaitForDatabase(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
try
{
if (!File.Exists(FileSystemLayout.PlexSecretsPath))

17
ErsatzTV/Services/RunOnce/CacheCleanerService.cs

@ -6,21 +6,32 @@ using Microsoft.EntityFrameworkCore; @@ -6,21 +6,32 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Services.RunOnce;
public class CacheCleanerService : IHostedService
public class CacheCleanerService : BackgroundService
{
private readonly ILogger<CacheCleanerService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public CacheCleanerService(
IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<CacheCleanerService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
await _systemStartup.WaitForDatabase(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
using IServiceScope scope = _serviceScopeFactory.CreateScope();
await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
ILocalFileSystem localFileSystem = scope.ServiceProvider.GetRequiredService<ILocalFileSystem>();
@ -57,6 +68,4 @@ public class CacheCleanerService : IHostedService @@ -57,6 +68,4 @@ public class CacheCleanerService : IHostedService
_logger.LogInformation("Done emptying transcode cache folder");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

16
ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs

@ -1,23 +1,29 @@ @@ -1,23 +1,29 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Services.RunOnce;
public class DatabaseMigratorService : IHostedService
public class DatabaseMigratorService : BackgroundService
{
private readonly ILogger<DatabaseMigratorService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public DatabaseMigratorService(
IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<DatabaseMigratorService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
_logger.LogInformation("Applying database migrations");
using IServiceScope scope = _serviceScopeFactory.CreateScope();
@ -25,8 +31,8 @@ public class DatabaseMigratorService : IHostedService @@ -25,8 +31,8 @@ public class DatabaseMigratorService : IHostedService
await dbContext.Database.MigrateAsync(cancellationToken);
await DbInitializer.Initialize(dbContext, cancellationToken);
_systemStartup.DatabaseIsReady();
_logger.LogInformation("Done applying database migrations");
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

10
ErsatzTV/Services/RunOnce/EndpointValidatorService.cs

@ -4,7 +4,7 @@ using ErsatzTV.Core; @@ -4,7 +4,7 @@ using ErsatzTV.Core;
namespace ErsatzTV.Services.RunOnce;
public class EndpointValidatorService : IHostedService
public class EndpointValidatorService : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly ILogger<EndpointValidatorService> _logger;
@ -15,8 +15,10 @@ public class EndpointValidatorService : IHostedService @@ -15,8 +15,10 @@ public class EndpointValidatorService : IHostedService
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
string urls = _configuration.GetValue<string>("Kestrel:Endpoints:Http:Url");
if (urls.Split(";").Length > 1)
{
@ -50,9 +52,5 @@ public class EndpointValidatorService : IHostedService @@ -50,9 +52,5 @@ public class EndpointValidatorService : IHostedService
"Server will listen on port {Port} - try UI at {UI}",
Settings.ListenPort,
$"http://localhost:{Settings.ListenPort}{baseUrl}");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

21
ErsatzTV/Services/RunOnce/LoadLoggingLevelService.cs

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Serilog.Core;
@ -5,15 +6,27 @@ using Serilog.Events; @@ -5,15 +6,27 @@ using Serilog.Events;
namespace ErsatzTV.Services.RunOnce;
public class LoadLoggingLevelService : IHostedService
public class LoadLoggingLevelService : BackgroundService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public LoadLoggingLevelService(IServiceScopeFactory serviceScopeFactory) =>
public LoadLoggingLevelService(IServiceScopeFactory serviceScopeFactory, SystemStartup systemStartup)
{
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
}
public async Task StartAsync(CancellationToken cancellationToken)
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
await _systemStartup.WaitForDatabase(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IConfigElementRepository configElementRepository =
scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
@ -26,6 +39,4 @@ public class LoadLoggingLevelService : IHostedService @@ -26,6 +39,4 @@ public class LoadLoggingLevelService : IHostedService
loggingLevelSwitch.MinimumLevel = logLevel;
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

11
ErsatzTV/Services/RunOnce/PlatformSettingsService.cs

@ -1,12 +1,11 @@ @@ -1,12 +1,11 @@
using System.Runtime.InteropServices;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.Extensions.Caching.Memory;
namespace ErsatzTV.Services.RunOnce;
public class PlatformSettingsService : IHostedService
public class PlatformSettingsService : BackgroundService
{
private readonly ILogger<PlatformSettingsService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
@ -19,11 +18,11 @@ public class PlatformSettingsService : IHostedService @@ -19,11 +18,11 @@ public class PlatformSettingsService : IHostedService
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
await Task.Yield();
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IRuntimeInfo runtimeInfo = scope.ServiceProvider.GetRequiredService<IRuntimeInfo>();
if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Linux) &&
Directory.Exists("/dev/dri"))
@ -38,6 +37,4 @@ public class PlatformSettingsService : IHostedService @@ -38,6 +37,4 @@ public class PlatformSettingsService : IHostedService
memoryCache.Set("ffmpeg.render_devices", devices);
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

21
ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs

@ -1,21 +1,32 @@ @@ -1,21 +1,32 @@
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using MediatR;
namespace ErsatzTV.Services.RunOnce;
public class RebuildSearchIndexService : IHostedService
public class RebuildSearchIndexService : BackgroundService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public RebuildSearchIndexService(IServiceScopeFactory serviceScopeFactory) =>
public RebuildSearchIndexService(IServiceScopeFactory serviceScopeFactory, SystemStartup systemStartup)
{
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
}
public async Task StartAsync(CancellationToken cancellationToken)
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
await _systemStartup.WaitForDatabase(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new RebuildSearchIndex(), cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

8
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -3,10 +3,12 @@ using ErsatzTV.Core; @@ -3,10 +3,12 @@ using ErsatzTV.Core;
namespace ErsatzTV.Services.RunOnce;
public class ResourceExtractorService : IHostedService
public class ResourceExtractorService : BackgroundService
{
public async Task StartAsync(CancellationToken cancellationToken)
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
if (!Directory.Exists(FileSystemLayout.ResourcesCacheFolder))
{
Directory.CreateDirectory(FileSystemLayout.ResourcesCacheFolder);
@ -60,8 +62,6 @@ public class ResourceExtractorService : IHostedService @@ -60,8 +62,6 @@ public class ResourceExtractorService : IHostedService
cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken)
{
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}");

2
ErsatzTV/Services/ScannerService.cs

@ -30,6 +30,8 @@ public class ScannerService : BackgroundService @@ -30,6 +30,8 @@ public class ScannerService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
try
{
_logger.LogInformation("Scanner service started");

75
ErsatzTV/Services/SchedulerService.cs

@ -9,6 +9,7 @@ using ErsatzTV.Application.MediaCollections; @@ -9,6 +9,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Plex;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
@ -20,6 +21,7 @@ namespace ErsatzTV.Services; @@ -20,6 +21,7 @@ namespace ErsatzTV.Services;
public class SchedulerService : BackgroundService
{
private readonly IEntityLocker _entityLocker;
private readonly SystemStartup _systemStartup;
private readonly ILogger<SchedulerService> _logger;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
private readonly IServiceScopeFactory _serviceScopeFactory;
@ -30,61 +32,80 @@ public class SchedulerService : BackgroundService @@ -30,61 +32,80 @@ public class SchedulerService : BackgroundService
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker,
SystemStartup systemStartup,
ILogger<SchedulerService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_workerChannel = workerChannel;
_scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker;
_systemStartup = systemStartup;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
DateTime firstRun = DateTime.Now;
// run once immediately at startup
if (!cancellationToken.IsCancellationRequested)
await Task.Yield();
await _systemStartup.WaitForSearchIndex(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
await DoWork(cancellationToken);
return;
}
while (!cancellationToken.IsCancellationRequested)
try
{
int currentMinutes = DateTime.Now.TimeOfDay.Minutes;
int toWait = currentMinutes < 30 ? 30 - currentMinutes : 60 - currentMinutes;
_logger.LogDebug("Scheduler sleeping for {Minutes} minutes", toWait);
_logger.LogInformation("Scheduler service started");
try
{
await Task.Delay(TimeSpan.FromMinutes(toWait), cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
DateTime firstRun = DateTime.Now;
// run once immediately at startup
if (!cancellationToken.IsCancellationRequested)
{
// do nothing
await DoWork(cancellationToken);
}
if (!cancellationToken.IsCancellationRequested)
while (!cancellationToken.IsCancellationRequested)
{
var roundedMinute = (int)(Math.Round(DateTime.Now.Minute / 5.0) * 5);
if (roundedMinute % 30 == 0)
int currentMinutes = DateTime.Now.TimeOfDay.Minutes;
int toWait = currentMinutes < 30 ? 30 - currentMinutes : 60 - currentMinutes;
_logger.LogDebug("Scheduler sleeping for {Minutes} minutes", toWait);
try
{
// check for playouts to reset every 30 minutes
await ResetPlayouts(cancellationToken);
await Task.Delay(TimeSpan.FromMinutes(toWait), cancellationToken);
}
if (roundedMinute % 60 == 0 && DateTime.Now.Subtract(firstRun) > TimeSpan.FromHours(1))
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do other work every hour (on the hour)
await DoWork(cancellationToken);
// do nothing
}
else if (roundedMinute % 30 == 0)
if (!cancellationToken.IsCancellationRequested)
{
// release memory every 30 minutes no matter what
await ReleaseMemory(cancellationToken);
var roundedMinute = (int)(Math.Round(DateTime.Now.Minute / 5.0) * 5);
if (roundedMinute % 30 == 0)
{
// check for playouts to reset every 30 minutes
await ResetPlayouts(cancellationToken);
}
if (roundedMinute % 60 == 0 && DateTime.Now.Subtract(firstRun) > TimeSpan.FromHours(1))
{
// do other work every hour (on the hour)
await DoWork(cancellationToken);
}
else if (roundedMinute % 30 == 0)
{
// release memory every 30 minutes no matter what
await ReleaseMemory(cancellationToken);
}
}
}
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
_logger.LogInformation("Scheduler service shutting down");
}
}
private async Task DoWork(CancellationToken cancellationToken)

2
ErsatzTV/Services/SearchIndexService.cs

@ -24,6 +24,8 @@ public class SearchIndexService : BackgroundService @@ -24,6 +24,8 @@ public class SearchIndexService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
try
{
_logger.LogInformation("Search index worker service started");

2
ErsatzTV/Services/WorkerService.cs

@ -29,6 +29,8 @@ public class WorkerService : BackgroundService @@ -29,6 +29,8 @@ public class WorkerService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await Task.Yield();
try
{
_logger.LogInformation("Worker service started");

194
ErsatzTV/Shared/MainLayout.razor

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
@implements IDisposable
@inject NavigationManager NavigationManager
@inject IMediator Mediator
@inject SystemStartup SystemStartup
<MudThemeProvider Theme="_ersatzTvTheme"/>
<MudDialogProvider DisableBackdropClick="true"/>
@ -17,54 +18,60 @@ @@ -17,54 +18,60 @@
<img src="images/ersatztv.png" alt="ErsatzTV"/>
</a>
</div>
<div class="search-form">
<EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())">
<MudTextField T="string"
@bind-Value="@Query"
AdornmentIcon="@Icons.Material.Filled.Search"
Adornment="Adornment.Start"
Variant="Variant.Outlined"
Immediate="true"
Class="search-bar"
@onclick="@(() => _isOpen = true)"
OnKeyUp="OnKeyUp">
</MudTextField>
<MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="true">
@if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3)
{
var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
if (matches.Any())
@if (SystemStartup.IsDatabaseReady && SystemStartup.IsSearchIndexReady)
{
<div class="search-form">
<EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())">
<MudTextField T="string"
@bind-Value="@Query"
AdornmentIcon="@Icons.Material.Filled.Search"
Adornment="Adornment.Start"
Variant="Variant.Outlined"
Immediate="true"
Class="search-bar"
@onclick="@(() => _isOpen = true)"
OnKeyUp="OnKeyUp">
</MudTextField>
<MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="true">
@if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3)
{
<MudList Clickable="true" Dense="true">
@foreach (SearchTargetViewModel searchTarget in matches)
{
<MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))">
<MudText Typo="Typo.body1">@searchTarget.Name</MudText>
<MudText Typo="Typo.subtitle1" Class="mud-text-disabled">
@(searchTarget.Kind switch
{
SearchTargetKind.Channel => "Channel",
SearchTargetKind.FFmpegProfile => "FFmpeg Profile",
SearchTargetKind.ChannelWatermark => "Channel Watermark",
SearchTargetKind.Collection => "Collection",
SearchTargetKind.MultiCollection => "Multi Collection",
SearchTargetKind.SmartCollection => "Smart Collection",
SearchTargetKind.Schedule => "Schedule",
SearchTargetKind.ScheduleItems => "Schedule Items",
_ => string.Empty
})
</MudText>
</MudListItem>
}
</MudList>
var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
if (matches.Any())
{
<MudList Clickable="true" Dense="true">
@foreach (SearchTargetViewModel searchTarget in matches)
{
<MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))">
<MudText Typo="Typo.body1">@searchTarget.Name</MudText>
<MudText Typo="Typo.subtitle1" Class="mud-text-disabled">
@(searchTarget.Kind switch
{
SearchTargetKind.Channel => "Channel",
SearchTargetKind.FFmpegProfile => "FFmpeg Profile",
SearchTargetKind.ChannelWatermark => "Channel Watermark",
SearchTargetKind.Collection => "Collection",
SearchTargetKind.MultiCollection => "Multi Collection",
SearchTargetKind.SmartCollection => "Smart Collection",
SearchTargetKind.Schedule => "Schedule",
SearchTargetKind.ScheduleItems => "Schedule Items",
_ => string.Empty
})
</MudText>
</MudListItem>
}
</MudList>
}
}
}
</MudPopover>
</EditForm>
</div>
</MudPopover>
</EditForm>
</div>
}
<MudSpacer/>
<MudLink Color="Color.Info" Href="iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Color="Color.Info" Href="iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
@if (SystemStartup.IsDatabaseReady && SystemStartup.IsSearchIndexReady)
{
<MudLink Color="Color.Info" Href="iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Color="Color.Info" Href="iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
}
@* <MudLink Color="Color.Info" Href="/swagger" Target="_blank" Class="mr-4" Underline="Underline.None">API</MudLink> *@
<MudTooltip Text="Documentation">
<MudIconButton Icon="@Icons.Material.Filled.Help" Color="Color.Primary" Link="https://ersatztv.org" Target="_blank"/>
@ -83,45 +90,48 @@ @@ -83,45 +90,48 @@
</form>
</AuthorizeView>
</MudAppBar>
<MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="channels">Channels</MudNavLink>
<MudNavLink Href="ffmpeg">FFmpeg Profiles</MudNavLink>
<MudNavLink Href="watermarks">Watermarks</MudNavLink>
<MudNavGroup Title="Media Sources" Expanded="true">
<MudNavLink Href="media/sources/local">Local</MudNavLink>
<MudNavLink Href="media/sources/emby">Emby</MudNavLink>
<MudNavLink Href="media/sources/jellyfin">Jellyfin</MudNavLink>
<MudNavLink Href="media/sources/plex">Plex</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Media" Expanded="true">
<MudNavLink Href="media/libraries">Libraries</MudNavLink>
<MudNavLink Href="media/trash">Trash</MudNavLink>
<MudNavLink Href="media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="media/movies">Movies</MudNavLink>
<MudNavLink Href="media/music/artists">Music</MudNavLink>
<MudNavLink Href="media/other/videos">Other Videos</MudNavLink>
<MudNavLink Href="media/music/songs">Songs</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Lists" Expanded="true">
<MudNavLink Href="media/collections">Collections</MudNavLink>
<MudNavLink Href="media/trakt/lists">Trakt Lists</MudNavLink>
<MudNavLink Href="media/filler/presets">Filler Presets</MudNavLink>
</MudNavGroup>
<MudNavLink Href="schedules">Schedules</MudNavLink>
<MudNavLink Href="playouts">Playouts</MudNavLink>
<MudNavLink Href="settings">Settings</MudNavLink>
<MudNavGroup Title="Support" Expanded="true">
<MudNavLink Href="system/logs">Logs</MudNavLink>
<MudNavLink Href="system/troubleshooting">Troubleshooting</MudNavLink>
</MudNavGroup>
<MudDivider Class="my-6" DividerType="DividerType.Middle"/>
<MudContainer Style="text-align: right" Class="mr-6">
<MudText Typo="Typo.body2">ErsatzTV Version</MudText>
<MudText Typo="Typo.body2" Color="Color.Info">@InfoVersion</MudText>
</MudContainer>
</MudNavMenu>
</MudDrawer>
@if (SystemStartup.IsDatabaseReady && SystemStartup.IsSearchIndexReady)
{
<MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="channels">Channels</MudNavLink>
<MudNavLink Href="ffmpeg">FFmpeg Profiles</MudNavLink>
<MudNavLink Href="watermarks">Watermarks</MudNavLink>
<MudNavGroup Title="Media Sources" Expanded="true">
<MudNavLink Href="media/sources/local">Local</MudNavLink>
<MudNavLink Href="media/sources/emby">Emby</MudNavLink>
<MudNavLink Href="media/sources/jellyfin">Jellyfin</MudNavLink>
<MudNavLink Href="media/sources/plex">Plex</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Media" Expanded="true">
<MudNavLink Href="media/libraries">Libraries</MudNavLink>
<MudNavLink Href="media/trash">Trash</MudNavLink>
<MudNavLink Href="media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="media/movies">Movies</MudNavLink>
<MudNavLink Href="media/music/artists">Music</MudNavLink>
<MudNavLink Href="media/other/videos">Other Videos</MudNavLink>
<MudNavLink Href="media/music/songs">Songs</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Lists" Expanded="true">
<MudNavLink Href="media/collections">Collections</MudNavLink>
<MudNavLink Href="media/trakt/lists">Trakt Lists</MudNavLink>
<MudNavLink Href="media/filler/presets">Filler Presets</MudNavLink>
</MudNavGroup>
<MudNavLink Href="schedules">Schedules</MudNavLink>
<MudNavLink Href="playouts">Playouts</MudNavLink>
<MudNavLink Href="settings">Settings</MudNavLink>
<MudNavGroup Title="Support" Expanded="true">
<MudNavLink Href="system/logs">Logs</MudNavLink>
<MudNavLink Href="system/troubleshooting">Troubleshooting</MudNavLink>
</MudNavGroup>
<MudDivider Class="my-6" DividerType="DividerType.Middle"/>
<MudContainer Style="text-align: right" Class="mr-6">
<MudText Typo="Typo.body2">ErsatzTV Version</MudText>
<MudText Typo="Typo.body2" Color="Color.Info">@InfoVersion</MudText>
</MudContainer>
</MudNavMenu>
</MudDrawer>
}
<MudMainContent>
@Body
</MudMainContent>
@ -140,8 +150,17 @@ @@ -140,8 +150,17 @@
private bool _isOpen;
private List<SearchTargetViewModel> _searchTargets;
protected override void OnInitialized()
{
SystemStartup.OnDatabaseReady += OnStartupProgress;
SystemStartup.OnSearchIndexReady += OnStartupProgress;
}
public void Dispose()
{
SystemStartup.OnDatabaseReady -= OnStartupProgress;
SystemStartup.OnSearchIndexReady -= OnStartupProgress;
_cts.Cancel();
_cts.Dispose();
}
@ -185,12 +204,17 @@ @@ -185,12 +204,17 @@
}
}
private async void OnStartupProgress(object sender, EventArgs e)
{
await InvokeAsync(StateHasChanged);
}
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
_query = NavigationManager.Uri.GetSearchQuery();
if (_searchTargets is null)
if (SystemStartup.IsDatabaseReady && _searchTargets is null)
{
_searchTargets = await Mediator.Send(new QuerySearchTargets(), _cts.Token);
}

1
ErsatzTV/Startup.cs

@ -521,6 +521,7 @@ public class Startup @@ -521,6 +521,7 @@ public class Startup
services.AddSingleton<ITempFilePool, TempFilePool>();
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();
services.AddSingleton<RecyclableMemoryStreamManager>();
services.AddSingleton<SystemStartup>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
AddChannel<IJellyfinBackgroundServiceRequest>(services);

Loading…
Cancel
Save