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/).
- Only allow a single instance of ErsatzTV to run - Only allow a single instance of ErsatzTV to run
- This fixes some cases where the search index would become unusable - 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 ## [0.8.0-beta] - 2023-06-23
### Added ### Added
- Disable playout buttons and show spinning indicator when a playout is being modified (built/extended, or subtitles are being extracted) - 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>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly SystemStartup _systemStartup;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RebuildSearchIndexHandler> _logger; private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex; private readonly ISearchIndex _searchIndex;
@ -25,6 +26,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
SystemStartup systemStartup,
ILogger<RebuildSearchIndexHandler> logger) ILogger<RebuildSearchIndexHandler> logger)
{ {
_searchIndex = searchIndex; _searchIndex = searchIndex;
@ -33,6 +35,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_systemStartup = systemStartup;
} }
public async Task Handle(RebuildSearchIndex request, CancellationToken cancellationToken) public async Task Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
@ -63,5 +66,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
{ {
_logger.LogInformation("Search index is already version {Version}", _searchIndex.Version); _logger.LogInformation("Search index is already version {Version}", _searchIndex.Version);
} }
_systemStartup.SearchIndexIsReady();
} }
} }

33
ErsatzTV.Core/SystemStartup.cs

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

11
ErsatzTV/Services/EmbyService.cs

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

20
ErsatzTV/Services/FFmpegLocatorService.cs

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

11
ErsatzTV/Services/JellyfinService.cs

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

11
ErsatzTV/Services/PlexService.cs

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

17
ErsatzTV/Services/RunOnce/CacheCleanerService.cs

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

16
ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs

@ -1,23 +1,29 @@
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Services.RunOnce; namespace ErsatzTV.Services.RunOnce;
public class DatabaseMigratorService : IHostedService public class DatabaseMigratorService : BackgroundService
{ {
private readonly ILogger<DatabaseMigratorService> _logger; private readonly ILogger<DatabaseMigratorService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public DatabaseMigratorService( public DatabaseMigratorService(
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<DatabaseMigratorService> logger) ILogger<DatabaseMigratorService> logger)
{ {
_serviceScopeFactory = serviceScopeFactory; _serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
_logger = logger; _logger = logger;
} }
public async Task StartAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
await Task.Yield();
_logger.LogInformation("Applying database migrations"); _logger.LogInformation("Applying database migrations");
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
@ -25,8 +31,8 @@ public class DatabaseMigratorService : IHostedService
await dbContext.Database.MigrateAsync(cancellationToken); await dbContext.Database.MigrateAsync(cancellationToken);
await DbInitializer.Initialize(dbContext, cancellationToken); await DbInitializer.Initialize(dbContext, cancellationToken);
_systemStartup.DatabaseIsReady();
_logger.LogInformation("Done applying database migrations"); _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;
namespace ErsatzTV.Services.RunOnce; namespace ErsatzTV.Services.RunOnce;
public class EndpointValidatorService : IHostedService public class EndpointValidatorService : BackgroundService
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<EndpointValidatorService> _logger; private readonly ILogger<EndpointValidatorService> _logger;
@ -15,8 +15,10 @@ public class EndpointValidatorService : IHostedService
_logger = logger; _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"); string urls = _configuration.GetValue<string>("Kestrel:Endpoints:Http:Url");
if (urls.Split(";").Length > 1) if (urls.Split(";").Length > 1)
{ {
@ -50,9 +52,5 @@ public class EndpointValidatorService : IHostedService
"Server will listen on port {Port} - try UI at {UI}", "Server will listen on port {Port} - try UI at {UI}",
Settings.ListenPort, Settings.ListenPort,
$"http://localhost:{Settings.ListenPort}{baseUrl}"); $"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 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using Serilog.Core; using Serilog.Core;
@ -5,15 +6,27 @@ using Serilog.Events;
namespace ErsatzTV.Services.RunOnce; namespace ErsatzTV.Services.RunOnce;
public class LoadLoggingLevelService : IHostedService public class LoadLoggingLevelService : BackgroundService
{ {
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public LoadLoggingLevelService(IServiceScopeFactory serviceScopeFactory) => public LoadLoggingLevelService(IServiceScopeFactory serviceScopeFactory, SystemStartup systemStartup)
{
_serviceScopeFactory = serviceScopeFactory; _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(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
IConfigElementRepository configElementRepository = IConfigElementRepository configElementRepository =
scope.ServiceProvider.GetRequiredService<IConfigElementRepository>(); scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
@ -26,6 +39,4 @@ public class LoadLoggingLevelService : IHostedService
loggingLevelSwitch.MinimumLevel = logLevel; loggingLevelSwitch.MinimumLevel = logLevel;
} }
} }
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }

11
ErsatzTV/Services/RunOnce/PlatformSettingsService.cs

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

21
ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs

@ -1,21 +1,32 @@
using ErsatzTV.Application.Search; using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using MediatR; using MediatR;
namespace ErsatzTV.Services.RunOnce; namespace ErsatzTV.Services.RunOnce;
public class RebuildSearchIndexService : IHostedService public class RebuildSearchIndexService : BackgroundService
{ {
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public RebuildSearchIndexService(IServiceScopeFactory serviceScopeFactory) => public RebuildSearchIndexService(IServiceScopeFactory serviceScopeFactory, SystemStartup systemStartup)
{
_serviceScopeFactory = serviceScopeFactory; _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(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new RebuildSearchIndex(), cancellationToken); 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;
namespace ErsatzTV.Services.RunOnce; 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)) if (!Directory.Exists(FileSystemLayout.ResourcesCacheFolder))
{ {
Directory.CreateDirectory(FileSystemLayout.ResourcesCacheFolder); Directory.CreateDirectory(FileSystemLayout.ResourcesCacheFolder);
@ -60,8 +62,6 @@ public class ResourceExtractorService : IHostedService
cancellationToken); cancellationToken);
} }
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken) private static async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken)
{ {
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}"); await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}");

2
ErsatzTV/Services/ScannerService.cs

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

75
ErsatzTV/Services/SchedulerService.cs

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

2
ErsatzTV/Services/SearchIndexService.cs

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

2
ErsatzTV/Services/WorkerService.cs

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

194
ErsatzTV/Shared/MainLayout.razor

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

1
ErsatzTV/Startup.cs

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

Loading…
Cancel
Save