294 lines
15 KiB
Plaintext
294 lines
15 KiB
Plaintext
@page "/settings/ffmpeg"
|
|
@using ErsatzTV.Application.Configuration
|
|
@using ErsatzTV.Application.FFmpegProfiles
|
|
@using ErsatzTV.Application.Filler
|
|
@using ErsatzTV.Application.MediaItems
|
|
@using ErsatzTV.Application.Resolutions
|
|
@using ErsatzTV.Application.Watermarks
|
|
@using ErsatzTV.Core.Domain.Filler
|
|
@using ErsatzTV.Core.FFmpeg
|
|
@using ErsatzTV.FFmpeg.OutputFormat
|
|
@implements IDisposable
|
|
@inject IMediator Mediator
|
|
@inject ISnackbar Snackbar
|
|
@inject ILogger<FFmpegSettings> Logger
|
|
@inject IDialogService Dialog
|
|
|
|
<MudForm @ref="_form" @bind-IsValid="@_success" Style="max-height: 100%">
|
|
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
|
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveFFmpegSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</MudButton>
|
|
</MudPaper>
|
|
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
|
<MudText Typo="Typo.h5" Class="mb-2">FFmpeg</MudText>
|
|
<MudDivider Class="mb-6"/>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>FFmpeg Path</MudText>
|
|
</div>
|
|
<MudTextField @bind-Value="_ffmpegSettings.FFmpegPath" HelperText="The full path to the ffmpeg executable file" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>FFprobe Path</MudText>
|
|
</div>
|
|
<MudTextField @bind-Value="_ffmpegSettings.FFprobePath" HelperText="The full path to the ffprobe executable file" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Default FFmpeg Profile</MudText>
|
|
</div>
|
|
<MudSelect @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" HelperText="The FFmpeg Profile to use when creating new channels">
|
|
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
|
|
{
|
|
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Preferred Audio Language</MudText>
|
|
</div>
|
|
<MudSelect @bind-Value="_ffmpegSettings.PreferredAudioLanguageCode" Required="true" RequiredError="Preferred Language Code is required!">
|
|
@foreach (LanguageCodeViewModel culture in _availableCultures)
|
|
{
|
|
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Use Embedded Subtitles</MudText>
|
|
</div>
|
|
<MudCheckBox @bind-Value="_ffmpegSettings.UseEmbeddedSubtitles" Dense="true"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Extract Embedded (Text) Subtitles</MudText>
|
|
</div>
|
|
<MudCheckBox @bind-Value="_ffmpegSettings.ExtractEmbeddedSubtitles" Disabled="@(_ffmpegSettings.UseEmbeddedSubtitles == false)" Dense="true"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Probe For Interlaced Frames</MudText>
|
|
</div>
|
|
<MudCheckBox @bind-Value="_ffmpegSettings.ProbeForInterlacedFrames" Dense="true"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Save Troubleshooting Reports To Disk</MudText>
|
|
</div>
|
|
<MudCheckBox @bind-Value="_ffmpegSettings.SaveReports" Dense="true"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Global Watermark</MudText>
|
|
</div>
|
|
<MudSelect @bind-Value="_ffmpegSettings.GlobalWatermarkId" Clearable="true">
|
|
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
|
@foreach (WatermarkViewModel watermark in _watermarks)
|
|
{
|
|
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Global Fallback Filler</MudText>
|
|
</div>
|
|
<MudSelect @bind-Value="_ffmpegSettings.GlobalFallbackFillerId" Clearable="true">
|
|
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
|
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
|
|
{
|
|
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>HLS Segmenter Idle Timeout</MudText>
|
|
</div>
|
|
<MudTextField @bind-Value="_ffmpegSettings.HlsSegmenterIdleTimeout" Validation="@(new Func<int, string>(ValidateHlsSegmenterIdleTimeout))" Required="true" RequiredError="HLS Segmenter idle timeout is required!" Adornment="Adornment.End" AdornmentText="seconds" HelperText="The number of seconds to continue transcoding a channel while no requests have been received from any client"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Work-Ahead HLS Segmenter Limit</MudText>
|
|
</div>
|
|
<MudTextField @bind-Value="_ffmpegSettings.WorkAheadSegmenterLimit" Validation="@(new Func<int, string>(ValidateWorkAheadSegmenterLimit))" Required="true" RequiredError="Work-ahead HLS Segmenter limit is required!" HelperText="The number of segmenters (channels) that will work-ahead (transcode at maximum speed) simultaneously, if multiple channels are being watched"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>HLS Segmenter Initial Segment Count</MudText>
|
|
</div>
|
|
<MudTextField @bind-Value="_ffmpegSettings.InitialSegmentCount" Validation="@(new Func<int, string>(ValidateInitialSegmentCount))" Required="true" RequiredError="HLS Segmenter initial segment count is required!" HelperText="Delays stream start until the specified number of (4-second) segments have been transcoded; a larger number will mean slower initial playback, but potentially less buffering"/>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>HLS Direct Output Format</MudText>
|
|
</div>
|
|
<MudSelect @bind-Value="_ffmpegSettings.HlsDirectOutputFormat" Clearable="true">
|
|
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.MpegTs">MPEG-TS</MudSelectItem>
|
|
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mp4">MP4</MudSelectItem>
|
|
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mkv">MKV</MudSelectItem>
|
|
</MudSelect>
|
|
</MudStack>
|
|
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
|
<div class="d-flex">
|
|
<MudText>Default MPEG-TS Script</MudText>
|
|
</div>
|
|
<MudSelect @bind-Value="_ffmpegSettings.DefaultMpegTsScript" HelperText="The MPEG-TS script to use when streaming">
|
|
@foreach (MpegTsScript script in _mpegTsScripts)
|
|
{
|
|
<MudSelectItem Value="@script.Id">@script.Name</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
</MudStack>
|
|
<MudText Typo="Typo.h5" Class="mb-2 mt-10">Custom Resolutions</MudText>
|
|
<MudDivider Class="mb-6"/>
|
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddCustomResolution())" StartIcon="@Icons.Material.Filled.Add">
|
|
Add Custom Resolution
|
|
</MudButton>
|
|
<MudTable Hover="true" Items="_customResolutions" Dense="true" Class="mt-6 mb-12" Style="max-width: 800px">
|
|
<ColGroup>
|
|
<col/>
|
|
<col style="width: 60px;"/>
|
|
</ColGroup>
|
|
<HeaderContent>
|
|
</HeaderContent>
|
|
<RowTemplate>
|
|
<MudTd DataLabel="Resolution">@context.Name</MudTd>
|
|
<MudTd>
|
|
<div style="align-items: center; display: flex;">
|
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
|
OnClick="@(() => DeleteCustomResolution(context))">
|
|
</MudIconButton>
|
|
</div>
|
|
</MudTd>
|
|
</RowTemplate>
|
|
</MudTable>
|
|
</MudContainer>
|
|
</div>
|
|
</MudForm>
|
|
|
|
@code {
|
|
private CancellationTokenSource _cts;
|
|
|
|
private MudForm _form;
|
|
private bool _success;
|
|
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
|
|
private FFmpegSettingsViewModel _ffmpegSettings = new();
|
|
private List<LanguageCodeViewModel> _availableCultures = [];
|
|
private List<WatermarkViewModel> _watermarks = [];
|
|
private List<FillerPresetViewModel> _fillerPresets = [];
|
|
private List<ResolutionViewModel> _customResolutions = [];
|
|
private readonly List<MpegTsScript> _mpegTsScripts = [];
|
|
|
|
public void Dispose()
|
|
{
|
|
_cts?.Cancel();
|
|
_cts?.Dispose();
|
|
}
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
_cts?.Cancel();
|
|
_cts?.Dispose();
|
|
_cts = new CancellationTokenSource();
|
|
var token = _cts.Token;
|
|
|
|
try
|
|
{
|
|
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), token);
|
|
|
|
_ffmpegSettings = await Mediator.Send(new GetFFmpegSettings(), token);
|
|
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
|
|
_availableCultures = await Mediator.Send(new GetAllLanguageCodes(), token);
|
|
_watermarks = await Mediator.Send(new GetAllWatermarks(), token);
|
|
_fillerPresets = await Mediator.Send(new GetAllFillerPresets(), token)
|
|
.Map(list => list.Filter(fp => fp.FillerKind == FillerKind.Fallback).ToList());
|
|
|
|
_mpegTsScripts.Clear();
|
|
_mpegTsScripts.AddRange(await Mediator.Send(new GetMpegTsScripts(), token));
|
|
|
|
await RefreshCustomResolutions(token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// do nothing
|
|
}
|
|
}
|
|
|
|
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
|
|
|
|
private static string ValidateHlsSegmenterIdleTimeout(int idleTimeout) => idleTimeout < 30 ? "HLS Segmenter idle timeout must be greater than or equal to 30" : null;
|
|
|
|
private static string ValidateWorkAheadSegmenterLimit(int limit) => limit < 0 ? "Work-Ahead HLS Segmenter limit must be greater than or equal to 0" : 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 SaveFFmpegSettings()
|
|
{
|
|
await _form.Validate();
|
|
if (_success)
|
|
{
|
|
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);
|
|
},
|
|
Right: _ =>
|
|
{
|
|
Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success);
|
|
_success = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
private async Task RefreshCustomResolutions(CancellationToken cancellationToken) =>
|
|
_customResolutions = await Mediator.Send(new GetAllResolutions(), cancellationToken)
|
|
.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 is { Canceled: false, Data: 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(_cts.Token);
|
|
}
|
|
}
|
|
}
|
|
|
|
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(_cts.Token);
|
|
}
|
|
}
|
|
|
|
}
|