Browse Source

add custom resolution management (#1326)

* update some dependencies

* add custom resolution management
pull/1317/head
Jason Dove 2 years ago committed by GitHub
parent
commit
a9c93ff498
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
  3. 8
      ErsatzTV.Application/FFmpegProfiles/Mapper.cs
  4. 5
      ErsatzTV.Application/Resolutions/Commands/CreateCustomResolution.cs
  5. 76
      ErsatzTV.Application/Resolutions/Commands/CreateCustomResolutionHandler.cs
  6. 5
      ErsatzTV.Application/Resolutions/Commands/DeleteCustomResolution.cs
  7. 40
      ErsatzTV.Application/Resolutions/Commands/DeleteCustomResolutionHandler.cs
  8. 2
      ErsatzTV.Application/Resolutions/FFmpegProfileResolutionViewModel.cs
  9. 2
      ErsatzTV.Application/Resolutions/Mapper.cs
  10. 4
      ErsatzTV.Application/Resolutions/Queries/GetAllResolutionsHandler.cs
  11. 1
      ErsatzTV.Core/Domain/Resolution.cs
  12. 7
      ErsatzTV.Infrastructure/Data/Configurations/ResolutionConfiguration.cs
  13. 4424
      ErsatzTV.Infrastructure/Migrations/20230625130236_Add_Resolution_IsCustom.Designer.cs
  14. 29
      ErsatzTV.Infrastructure/Migrations/20230625130236_Add_Resolution_IsCustom.cs
  15. 5
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  16. 4
      ErsatzTV/ErsatzTV.csproj
  17. 2
      ErsatzTV/Pages/ChannelEditor.razor
  18. 2
      ErsatzTV/Pages/CollectionEditor.razor
  19. 2
      ErsatzTV/Pages/FFmpegEditor.razor
  20. 2
      ErsatzTV/Pages/FillerPresetEditor.razor
  21. 2
      ErsatzTV/Pages/LocalLibraryEditor.razor
  22. 2
      ErsatzTV/Pages/LocalLibraryPathEditor.razor
  23. 2
      ErsatzTV/Pages/MultiCollectionEditor.razor
  24. 2
      ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor
  25. 2
      ErsatzTV/Pages/PlayoutEditor.razor
  26. 2
      ErsatzTV/Pages/ScheduleEditor.razor
  27. 2
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  28. 2
      ErsatzTV/Pages/Search.razor
  29. 152
      ErsatzTV/Pages/Settings.razor
  30. 2
      ErsatzTV/Pages/WatermarkEditor.razor
  31. 51
      ErsatzTV/Shared/AddCustomResolutionDialog.razor
  32. 2
      ErsatzTV/Shared/RemoteMediaSourceEditor.razor
  33. 2
      ErsatzTV/Shared/RemoteMediaSourcePathReplacementsEditor.razor
  34. 7
      ErsatzTV/ViewModels/ResolutionEditViewModel.cs
  35. 1
      ErsatzTV/_Imports.razor

4
CHANGELOG.md

@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add custom resolution management to `Settings` page
### Fixed
- Only allow a single instance of ErsatzTV to run
- This fixes some cases where the search index would become unusable
@ -13,7 +16,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -13,7 +16,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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)

1
ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings

@ -33,6 +33,7 @@ @@ -33,6 +33,7 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=plex_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@EntryIndexedValue">True</s:Boolean>

8
ErsatzTV.Application/FFmpegProfiles/Mapper.cs

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles;
@ -15,7 +14,7 @@ internal static class Mapper @@ -15,7 +14,7 @@ internal static class Mapper
profile.VaapiDriver,
profile.VaapiDevice,
profile.QsvExtraHardwareFrames,
Project(profile.Resolution),
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
profile.VideoFormat,
profile.BitDepth,
profile.VideoBitrate,
@ -57,7 +56,4 @@ internal static class Mapper @@ -57,7 +56,4 @@ internal static class Mapper
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.DeinterlaceVideo);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
}

5
ErsatzTV.Application/Resolutions/Commands/CreateCustomResolution.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Resolutions;
public record CreateCustomResolution(int Width, int Height) : IRequest<Option<BaseError>>;

76
ErsatzTV.Application/Resolutions/Commands/CreateCustomResolutionHandler.cs

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Resolutions;
public class CreateCustomResolutionHandler : IRequestHandler<CreateCustomResolution, Option<BaseError>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateCustomResolutionHandler(IDbContextFactory<TvContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Option<BaseError>> Handle(CreateCustomResolution request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Resolution> validation = await Validate(dbContext, request);
return await validation.Match(
r => PersistResolution(dbContext, r, cancellationToken),
error => Task.FromResult<Option<BaseError>>(error.Join()));
}
private static async Task<Option<BaseError>> PersistResolution(
TvContext dbContext,
Resolution resolution,
CancellationToken cancellationToken)
{
try
{
await dbContext.Resolutions.AddAsync(resolution, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Option<BaseError>.None;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private static Task<Validation<BaseError, Resolution>> Validate(
TvContext dbContext,
CreateCustomResolution request) =>
ResolutionMustBeUnique(dbContext, request)
.MapT(
_ => new Resolution
{
Name = $"{request.Width}x{request.Height}",
Width = request.Width,
Height = request.Height,
IsCustom = true
});
private static async Task<Validation<BaseError, Unit>> ResolutionMustBeUnique(
TvContext dbContext,
CreateCustomResolution request)
{
Option<Resolution> maybeExisting = await dbContext.Resolutions
.FirstOrDefaultAsync(r => r.Height == request.Height && r.Width == request.Width)
.Map(Optional);
if (maybeExisting.IsSome)
{
return BaseError.New("Resolution width and height must be unique");
}
if (request.Height <= 0 || request.Width <= 0)
{
return BaseError.New("Resolution width or height is invalid");
}
return Unit.Default;
}
}

5
ErsatzTV.Application/Resolutions/Commands/DeleteCustomResolution.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Resolutions;
public record DeleteCustomResolution(int ResolutionId) : IRequest<Option<BaseError>>;

40
ErsatzTV.Application/Resolutions/Commands/DeleteCustomResolutionHandler.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Resolutions;
public class DeleteCustomResolutionHandler : IRequestHandler<DeleteCustomResolution, Option<BaseError>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteCustomResolutionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<BaseError>> Handle(DeleteCustomResolution request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Resolution> maybeResolution = await dbContext.Resolutions
.AsNoTracking()
.SelectOneAsync(p => p.Id, p => p.Id == request.ResolutionId && p.IsCustom == true);
foreach (Resolution resolution in maybeResolution)
{
// reset any ffmpeg profiles using this resolution to 1920x1080
await dbContext.Connection.ExecuteAsync(
@"UPDATE FFmpegProfile SET ResolutionId = 3 WHERE ResolutionId = @ResolutionId",
new { request.ResolutionId });
dbContext.Resolutions.Remove(resolution);
await dbContext.SaveChangesAsync(cancellationToken);
}
return maybeResolution.IsNone
? BaseError.New($"Resolution {request.ResolutionId} does not exist.")
: Option<BaseError>.None;
}
}

2
ErsatzTV.Application/Resolutions/FFmpegProfileResolutionViewModel.cs

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Resolutions;
public record ResolutionViewModel(int Id, string Name, int Width, int Height);
public record ResolutionViewModel(int Id, string Name, int Width, int Height, bool IsCustom);

2
ErsatzTV.Application/Resolutions/Mapper.cs

@ -5,5 +5,5 @@ namespace ErsatzTV.Application.Resolutions; @@ -5,5 +5,5 @@ namespace ErsatzTV.Application.Resolutions;
internal static class Mapper
{
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height, resolution.IsCustom);
}

4
ErsatzTV.Application/Resolutions/Queries/GetAllResolutionsHandler.cs

@ -15,9 +15,9 @@ public class GetAllResolutionsHandler : IRequestHandler<GetAllResolutions, List< @@ -15,9 +15,9 @@ public class GetAllResolutionsHandler : IRequestHandler<GetAllResolutions, List<
GetAllResolutions request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Resolutions
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
.Map(list => list.OrderBy(r => r.Width).ThenBy(r => r.Height).Map(ProjectToViewModel).ToList());
}
}

1
ErsatzTV.Core/Domain/Resolution.cs

@ -8,6 +8,7 @@ public class Resolution : IDisplaySize @@ -8,6 +8,7 @@ public class Resolution : IDisplaySize
public string Name { get; set; }
public int Height { get; set; }
public int Width { get; set; }
public bool IsCustom { get; set; }
public override string ToString() => $"{Width}x{Height}";
}

7
ErsatzTV.Infrastructure/Data/Configurations/ResolutionConfiguration.cs

@ -6,5 +6,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations; @@ -6,5 +6,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class ResolutionConfiguration : IEntityTypeConfiguration<Resolution>
{
public void Configure(EntityTypeBuilder<Resolution> builder) => builder.ToTable("Resolution");
public void Configure(EntityTypeBuilder<Resolution> builder)
{
builder.ToTable("Resolution");
builder.Property(r => r.IsCustom).HasDefaultValue(false);
}
}

4424
ErsatzTV.Infrastructure/Migrations/20230625130236_Add_Resolution_IsCustom.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure/Migrations/20230625130236_Add_Resolution_IsCustom.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class Add_Resolution_IsCustom : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCustom",
table: "Resolution",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCustom",
table: "Resolution");
}
}
}

5
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -1746,6 +1746,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1746,6 +1746,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<bool>("IsCustom")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Name")
.HasColumnType("TEXT");

4
ErsatzTV/ErsatzTV.csproj

@ -53,6 +53,7 @@ @@ -53,6 +53,7 @@
</Target>
<ItemGroup>
<PackageReference Include="Blazored.FluentValidation" Version="2.1.0" />
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.1.0" />
<PackageReference Include="FluentValidation" Version="11.5.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
@ -72,9 +73,8 @@ @@ -72,9 +73,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="6.4.1" />
<PackageReference Include="MudBlazor" Version="6.5.0" />
<PackageReference Include="NaturalSort.Extension" Version="4.0.0" />
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="11.1.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.3.2" />
<PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />

2
ErsatzTV/Pages/ChannelEditor.razor

@ -19,7 +19,7 @@ @@ -19,7 +19,7 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardHeader>
<CardHeaderContent>

2
ErsatzTV/Pages/CollectionEditor.razor

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Collection" : "Add Collection")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>

2
ErsatzTV/Pages/FFmpegEditor.razor

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardHeader>
<CardHeaderContent>

2
ErsatzTV/Pages/FillerPresetEditor.razor

@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Filler Preset" : "Add Filler Preset")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>

2
ErsatzTV/Pages/LocalLibraryEditor.razor

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Local Library" : "Add Local Library")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@SaveChangesAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>

2
ErsatzTV/Pages/LocalLibraryPathEditor.razor

@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
<MudText Typo="Typo.h4" Class="mb-4">@_library.Name - Add Local Library Path</MudText>
<div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudTextField T="string" Label="Media Kind" Disabled="true" Value="@(Enum.GetName(typeof(LibraryMediaKind), _library.MediaKind))"/>

2
ErsatzTV/Pages/MultiCollectionEditor.razor

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Multi Collection" : "Add Multi Collection")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>

2
ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor

@ -86,7 +86,7 @@ @@ -86,7 +86,7 @@
@if (_selectedItem is not null)
{
<EditForm Model="_selectedItem">
<FluentValidator/>
<FluentValidationValidator/>
<div style="display: flex; flex-direction: row;" class="mt-6">
<div style="flex-grow: 1; max-width: 400px;" class="mr-6">
<MudCard>

2
ErsatzTV/Pages/PlayoutEditor.razor

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<MudText Typo="Typo.h4" Class="mb-4">Add Playout</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudSelect T="ChannelViewModel"

2
ErsatzTV/Pages/ScheduleEditor.razor

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Schedule" : "Add Schedule")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>

2
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -89,7 +89,7 @@ @@ -89,7 +89,7 @@
@if (_selectedItem is not null)
{
<EditForm Model="_selectedItem">
<FluentValidator/>
<FluentValidationValidator/>
<div style="display: flex; flex-direction: row;" class="mt-6">
<div style="flex-grow: 1; max-width: 400px;" class="mr-6">
<MudCard>

2
ErsatzTV/Pages/Search.razor

@ -616,7 +616,7 @@ @@ -616,7 +616,7 @@
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<SaveAsSmartCollectionDialog>("Save As Smart Collection", options);
IDialogReference dialog = await Dialog.ShowAsync<SaveAsSmartCollectionDialog>("Save As Smart Collection", options);
DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is SmartCollectionViewModel collection)
{

152
ErsatzTV/Pages/Settings.razor

@ -6,13 +6,15 @@ @@ -6,13 +6,15 @@
@using ErsatzTV.Application.Watermarks
@using System.Globalization
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.Resolutions
@using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.FFmpeg.OutputFormat
@using Serilog.Events
@implements IDisposable
@inject IMediator _mediator
@inject ISnackbar _snackbar
@inject ILogger<Settings> _logger
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject ILogger<Settings> Logger
@inject IDialogService Dialog
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="display: flex; flex-direction: row">
<MudGrid>
@ -201,6 +203,45 @@ @@ -201,6 +203,45 @@
</MudCardActions>
</MudCard>
</MudStack>
<MudStack Class="mr-6">
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Custom Resolutions</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (_customResolutions.Any())
{
<MudTable Hover="true" Items="_customResolutions" Dense="true" Class="mt-6">
<ColGroup>
<col/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Resolution">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Delete Custom Resolution">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(() => DeleteCustomResolution(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddCustomResolution())" Class="ml-2">
Add Custom Resolution
</MudButton>
</MudCardActions>
</MudCard>
</MudStack>
</MudGrid>
</MudContainer>
@ -216,6 +257,7 @@ @@ -216,6 +257,7 @@
private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks;
private List<FillerPresetViewModel> _fillerPresets;
private List<ResolutionViewModel> _customResolutions;
private int _tunerCount;
private int _libraryRefreshInterval;
private PlayoutSettingsViewModel _playoutSettings;
@ -231,19 +273,21 @@ @@ -231,19 +273,21 @@
{
await LoadFFmpegProfilesAsync();
_ffmpegSettings = await _mediator.Send(new GetFFmpegSettings(), _cts.Token);
_ffmpegSettings = await Mediator.Send(new GetFFmpegSettings(), _cts.Token);
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
_availableCultures = await _mediator.Send(new GetAllLanguageCodes(), _cts.Token);
_watermarks = await _mediator.Send(new GetAllWatermarks(), _cts.Token);
_fillerPresets = await _mediator.Send(new GetAllFillerPresets(), _cts.Token)
_availableCultures = await Mediator.Send(new GetAllLanguageCodes(), _cts.Token);
_watermarks = await Mediator.Send(new GetAllWatermarks(), _cts.Token);
_fillerPresets = await Mediator.Send(new GetAllFillerPresets(), _cts.Token)
.Map(list => list.Filter(fp => fp.FillerKind == FillerKind.Fallback).ToList());
_tunerCount = await _mediator.Send(new GetHDHRTunerCount(), _cts.Token);
_tunerCount = await Mediator.Send(new GetHDHRTunerCount(), _cts.Token);
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
_libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval(), _cts.Token);
_libraryRefreshInterval = await Mediator.Send(new GetLibraryRefreshInterval(), _cts.Token);
_scannerSuccess = _libraryRefreshInterval is >= 0 and < 1_000_000;
_playoutSettings = await _mediator.Send(new GetPlayoutSettings(), _cts.Token);
_playoutSettings = await Mediator.Send(new GetPlayoutSettings(), _cts.Token);
_playoutSuccess = _playoutSettings.DaysToBuild > 0;
_generalSettings = await _mediator.Send(new GetGeneralSettings(), _cts.Token);
_generalSettings = await Mediator.Send(new GetGeneralSettings(), _cts.Token);
await RefreshCustomResolutions();
}
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
@ -265,66 +309,110 @@ @@ -265,66 +309,110 @@
private static string ValidateInitialSegmentCount(int count) => count < 1 ? "HLS Segmenter initial segment count must be greater than or equal to 1" : null;
private async Task LoadFFmpegProfilesAsync() =>
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles(), _cts.Token);
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), _cts.Token);
private async Task SaveFFmpegSettings()
{
Either<BaseError, Unit> result = await _mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings), _cts.Token);
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings), _cts.Token);
result.Match(
Left: error =>
{
_snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
},
Right: _ => _snackbar.Add("Successfully saved FFmpeg settings", Severity.Success));
Right: _ => Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success));
}
private async Task SaveHDHRSettings()
{
Either<BaseError, Unit> result = await _mediator.Send(new UpdateHDHRTunerCount(_tunerCount), _cts.Token);
Either<BaseError, Unit> result = await Mediator.Send(new UpdateHDHRTunerCount(_tunerCount), _cts.Token);
result.Match(
Left: error =>
{
_snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving HDHomeRun settings: {Error}", error.Value);
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving HDHomeRun settings: {Error}", error.Value);
},
Right: _ => _snackbar.Add("Successfully saved HDHomeRun settings", Severity.Success));
Right: _ => Snackbar.Add("Successfully saved HDHomeRun settings", Severity.Success));
}
private async Task SaveScannerSettings()
{
Either<BaseError, Unit> result = await _mediator.Send(new UpdateLibraryRefreshInterval(_libraryRefreshInterval), _cts.Token);
Either<BaseError, Unit> result = await Mediator.Send(new UpdateLibraryRefreshInterval(_libraryRefreshInterval), _cts.Token);
result.Match(
Left: error =>
{
_snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving scanner settings: {Error}", error.Value);
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving scanner settings: {Error}", error.Value);
},
Right: _ => _snackbar.Add("Successfully saved scanner settings", Severity.Success));
Right: _ => Snackbar.Add("Successfully saved scanner settings", Severity.Success));
}
private async Task SavePlayoutSettings()
{
Either<BaseError, Unit> result = await _mediator.Send(new UpdatePlayoutSettings(_playoutSettings), _cts.Token);
Either<BaseError, Unit> result = await Mediator.Send(new UpdatePlayoutSettings(_playoutSettings), _cts.Token);
result.Match(
Left: error =>
{
_snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving playout settings: {Error}", error.Value);
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving playout settings: {Error}", error.Value);
},
Right: _ => _snackbar.Add("Successfully saved playout settings", Severity.Success));
Right: _ => Snackbar.Add("Successfully saved playout settings", Severity.Success));
}
private async Task SaveGeneralSettings()
{
Either<BaseError, Unit> result = await _mediator.Send(new UpdateGeneralSettings(_generalSettings), _cts.Token);
Either<BaseError, Unit> result = await Mediator.Send(new UpdateGeneralSettings(_generalSettings), _cts.Token);
result.Match(
Left: error =>
{
_snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving general settings: {Error}", error.Value);
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving general settings: {Error}", error.Value);
},
Right: _ => _snackbar.Add("Successfully saved general settings", Severity.Success));
Right: _ => Snackbar.Add("Successfully saved general settings", Severity.Success));
}
private async Task RefreshCustomResolutions()
{
_customResolutions = await Mediator.Send(new GetAllResolutions(), _cts.Token)
.Map(list => list.Filter(r => r.IsCustom).ToList());
}
private async Task AddCustomResolution()
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
IDialogReference dialog = await Dialog.ShowAsync<AddCustomResolutionDialog>("Add Custom Resolution", options);
DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ResolutionEditViewModel resolution)
{
Option<BaseError> saveResult = await Mediator.Send(
new CreateCustomResolution(resolution.Width, resolution.Height),
_cts.Token);
foreach (BaseError error in saveResult)
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding custom resolution: {Error}", error.Value);
}
if (saveResult.IsNone)
{
await RefreshCustomResolutions();
}
}
}
private async Task DeleteCustomResolution(ResolutionViewModel resolution)
{
Option<BaseError> result = await Mediator.Send(new DeleteCustomResolution(resolution.Id), _cts.Token);
foreach (BaseError error in result)
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error deleting custom resolution: {Error}", error.Value);
}
if (result.IsNone)
{
await RefreshCustomResolutions();
}
}
}

2
ErsatzTV/Pages/WatermarkEditor.razor

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardHeader>
<CardHeaderContent>

51
ErsatzTV/Shared/AddCustomResolutionDialog.razor

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
<MudDialog>
<DialogContent>
<EditForm Model="@_model" OnSubmit="@(_ => Submit())">
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Label="Width" @bind-Value="@_model.Width" For="@(() => _model.Width)" Style="min-width: 400px"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Label="Height" @bind-Value="@_model.Height" For="@(() => _model.Height)" Style="min-width: 400px"/>
</MudElement>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">
Add
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
MudDialogInstance MudDialog { get; set; }
private readonly ResolutionEditViewModel _model = new();
private void Submit()
{
// if (!CanSubmit())
// {
// return;
// }
MudDialog.Close(DialogResult.Ok(_model));
}
private void Cancel(MouseEventArgs e)
{
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit
if (e.Detail == 0)
{
Submit();
}
else
{
MudDialog.Cancel();
}
}
}

2
ErsatzTV/Shared/RemoteMediaSourceEditor.razor

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
<MudText Typo="Typo.h4" Class="mb-4">@Name Media Source</MudText>
<div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudTextField Label="Address" @bind-Value="_model.Address" For="@(() => _model.Address)" Placeholder="http://192.168.1.100:8096"/>

2
ErsatzTV/Shared/RemoteMediaSourcePathReplacementsEditor.razor

@ -51,7 +51,7 @@ @@ -51,7 +51,7 @@
{
<div style="max-width: 400px;">
<EditForm Model="_selectedItem">
<FluentValidator/>
<FluentValidationValidator/>
<MudCard Class="mt-6">
<MudCardContent>
<MudTextField Label="@($"{Name} Path")"

7
ErsatzTV/ViewModels/ResolutionEditViewModel.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.ViewModels;
public class ResolutionEditViewModel
{
public int Width { get; set; }
public int Height { get; set; }
}

1
ErsatzTV/_Imports.razor

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Logging
@using Microsoft.JSInterop
@using Blazored.FluentValidation
@using LanguageExt
@using MediatR
@using MudBlazor

Loading…
Cancel
Save