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.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added
- Add custom resolution management to `Settings` page
### Fixed ### Fixed
- 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
@ -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 - A minimal UI will indicate when the database and search index are initializing
- The UI will automatically refresh when the initialization processes have completed - 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)

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

@ -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/=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_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/=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/=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_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@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 @@
using ErsatzTV.Application.Resolutions; using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
@ -15,7 +14,7 @@ internal static class Mapper
profile.VaapiDriver, profile.VaapiDriver,
profile.VaapiDevice, profile.VaapiDevice,
profile.QsvExtraHardwareFrames, profile.QsvExtraHardwareFrames,
Project(profile.Resolution), Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
profile.VideoFormat, profile.VideoFormat,
profile.BitDepth, profile.BitDepth,
profile.VideoBitrate, profile.VideoBitrate,
@ -57,7 +56,4 @@ internal static class Mapper
ffmpegProfile.AudioSampleRate, ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate, ffmpegProfile.NormalizeFramerate,
ffmpegProfile.DeinterlaceVideo); 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 @@
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 @@
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 @@
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 @@
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 @@
namespace ErsatzTV.Application.Resolutions; 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;
internal static class Mapper internal static class Mapper
{ {
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) => 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<
GetAllResolutions request, GetAllResolutions request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Resolutions return await dbContext.Resolutions
.ToListAsync(cancellationToken) .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
public string Name { get; set; } public string Name { get; set; }
public int Height { get; set; } public int Height { get; set; }
public int Width { get; set; } public int Width { get; set; }
public bool IsCustom { get; set; }
public override string ToString() => $"{Width}x{Height}"; public override string ToString() => $"{Width}x{Height}";
} }

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

@ -6,5 +6,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class ResolutionConfiguration : IEntityTypeConfiguration<Resolution> 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 @@
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
b.Property<int>("Height") b.Property<int>("Height")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("IsCustom")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

4
ErsatzTV/ErsatzTV.csproj

@ -53,6 +53,7 @@
</Target> </Target>
<ItemGroup> <ItemGroup>
<PackageReference Include="Blazored.FluentValidation" Version="2.1.0" />
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.1.0" /> <PackageReference Include="Bugsnag.AspNet.Core" Version="3.1.0" />
<PackageReference Include="FluentValidation" Version="11.5.2" /> <PackageReference Include="FluentValidation" Version="11.5.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
@ -72,9 +73,8 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </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="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="Refit.HttpClientFactory" Version="6.3.2" />
<PackageReference Include="Serilog" Version="2.12.0" /> <PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />

2
ErsatzTV/Pages/ChannelEditor.razor

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

2
ErsatzTV/Pages/CollectionEditor.razor

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

2
ErsatzTV/Pages/FFmpegEditor.razor

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

2
ErsatzTV/Pages/FillerPresetEditor.razor

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

2
ErsatzTV/Pages/LocalLibraryEditor.razor

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

2
ErsatzTV/Pages/LocalLibraryPathEditor.razor

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

2
ErsatzTV/Pages/MultiCollectionEditor.razor

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

2
ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor

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

2
ErsatzTV/Pages/PlayoutEditor.razor

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

2
ErsatzTV/Pages/ScheduleEditor.razor

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

2
ErsatzTV/Pages/ScheduleItemsEditor.razor

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

2
ErsatzTV/Pages/Search.razor

@ -616,7 +616,7 @@
{ {
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; 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; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is SmartCollectionViewModel collection) if (!result.Canceled && result.Data is SmartCollectionViewModel collection)
{ {

152
ErsatzTV/Pages/Settings.razor

@ -6,13 +6,15 @@
@using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Watermarks
@using System.Globalization @using System.Globalization
@using ErsatzTV.Application.Configuration @using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.Resolutions
@using ErsatzTV.Core.Domain.Filler @using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.FFmpeg.OutputFormat @using ErsatzTV.FFmpeg.OutputFormat
@using Serilog.Events @using Serilog.Events
@implements IDisposable @implements IDisposable
@inject IMediator _mediator @inject IMediator Mediator
@inject ISnackbar _snackbar @inject ISnackbar Snackbar
@inject ILogger<Settings> _logger @inject ILogger<Settings> Logger
@inject IDialogService Dialog
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="display: flex; flex-direction: row"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="display: flex; flex-direction: row">
<MudGrid> <MudGrid>
@ -201,6 +203,45 @@
</MudCardActions> </MudCardActions>
</MudCard> </MudCard>
</MudStack> </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> </MudGrid>
</MudContainer> </MudContainer>
@ -216,6 +257,7 @@
private List<CultureInfo> _availableCultures; private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks; private List<WatermarkViewModel> _watermarks;
private List<FillerPresetViewModel> _fillerPresets; private List<FillerPresetViewModel> _fillerPresets;
private List<ResolutionViewModel> _customResolutions;
private int _tunerCount; private int _tunerCount;
private int _libraryRefreshInterval; private int _libraryRefreshInterval;
private PlayoutSettingsViewModel _playoutSettings; private PlayoutSettingsViewModel _playoutSettings;
@ -231,19 +273,21 @@
{ {
await LoadFFmpegProfilesAsync(); 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); _success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
_availableCultures = await _mediator.Send(new GetAllLanguageCodes(), _cts.Token); _availableCultures = await Mediator.Send(new GetAllLanguageCodes(), _cts.Token);
_watermarks = await _mediator.Send(new GetAllWatermarks(), _cts.Token); _watermarks = await Mediator.Send(new GetAllWatermarks(), _cts.Token);
_fillerPresets = await _mediator.Send(new GetAllFillerPresets(), _cts.Token) _fillerPresets = await Mediator.Send(new GetAllFillerPresets(), _cts.Token)
.Map(list => list.Filter(fp => fp.FillerKind == FillerKind.Fallback).ToList()); .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)); _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; _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; _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; private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
@ -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 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() => private async Task LoadFFmpegProfilesAsync() =>
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles(), _cts.Token); _ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), _cts.Token);
private async Task SaveFFmpegSettings() 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( result.Match(
Left: error => Left: error =>
{ {
_snackbar.Add(error.Value, Severity.Error); Snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value); 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() 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( result.Match(
Left: error => Left: error =>
{ {
_snackbar.Add(error.Value, Severity.Error); Snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving HDHomeRun settings: {Error}", error.Value); 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() 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( result.Match(
Left: error => Left: error =>
{ {
_snackbar.Add(error.Value, Severity.Error); Snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving scanner settings: {Error}", error.Value); 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() 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( result.Match(
Left: error => Left: error =>
{ {
_snackbar.Add(error.Value, Severity.Error); Snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving playout settings: {Error}", error.Value); 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() 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( result.Match(
Left: error => Left: error =>
{ {
_snackbar.Add(error.Value, Severity.Error); Snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Unexpected error saving general settings: {Error}", error.Value); 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 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;"> <div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync"> <EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/> <FluentValidationValidator/>
<MudCard> <MudCard>
<MudCardHeader> <MudCardHeader>
<CardHeaderContent> <CardHeaderContent>

51
ErsatzTV/Shared/AddCustomResolutionDialog.razor

@ -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 @@
<MudText Typo="Typo.h4" Class="mb-4">@Name Media Source</MudText> <MudText Typo="Typo.h4" Class="mb-4">@Name Media Source</MudText>
<div style="max-width: 400px;"> <div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync"> <EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/> <FluentValidationValidator/>
<MudCard> <MudCard>
<MudCardContent> <MudCardContent>
<MudTextField Label="Address" @bind-Value="_model.Address" For="@(() => _model.Address)" Placeholder="http://192.168.1.100:8096"/> <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 @@
{ {
<div style="max-width: 400px;"> <div style="max-width: 400px;">
<EditForm Model="_selectedItem"> <EditForm Model="_selectedItem">
<FluentValidator/> <FluentValidationValidator/>
<MudCard Class="mt-6"> <MudCard Class="mt-6">
<MudCardContent> <MudCardContent>
<MudTextField Label="@($"{Name} Path")" <MudTextField Label="@($"{Name} Path")"

7
ErsatzTV/ViewModels/ResolutionEditViewModel.cs

@ -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 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using Blazored.FluentValidation
@using LanguageExt @using LanguageExt
@using MediatR @using MediatR
@using MudBlazor @using MudBlazor

Loading…
Cancel
Save