mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
267 lines
12 KiB
267 lines
12 KiB
@page "/channels" |
|
@using System.Globalization |
|
@using ErsatzTV.Application.Channels |
|
@using ErsatzTV.Application.Configuration |
|
@using ErsatzTV.Application.FFmpegProfiles |
|
@using ErsatzTV.Core.Interfaces.FFmpeg |
|
@implements IDisposable |
|
@inject IDialogService Dialog |
|
@inject IMediator Mediator |
|
@inject NavigationManager NavigationManager |
|
@inject IFFmpegSegmenterService SegmenterService |
|
|
|
<MudForm 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" Class="ml-6" StartIcon="@Icons.Material.Filled.Add" Href="channels/add"> |
|
Add Channel |
|
</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">Channels</MudText> |
|
<MudDivider Class="mb-6"/> |
|
<MudTable Hover="true" |
|
@bind-RowsPerPage="@_rowsPerPage" |
|
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<ChannelViewModel>>>(ServerReload))" |
|
@ref="_table"> |
|
<ColGroup> |
|
<MudHidden Breakpoint="Breakpoint.Xs"> |
|
<col style="width: 60px;"/> |
|
<col/> |
|
<col style="width: 15%"/> |
|
<col style="width: 15%"/> |
|
<col style="width: 15%"/> |
|
<col style="width: 15%"/> |
|
<col style="width: 240px;"/> |
|
</MudHidden> |
|
</ColGroup> |
|
<HeaderContent> |
|
<MudTh> |
|
<MudTableSortLabel InitialDirection="SortDirection.Ascending" SortBy="new Func<ChannelViewModel, object>(x => decimal.Parse(x.Number, CultureInfo.InvariantCulture))">Number</MudTableSortLabel> |
|
</MudTh> |
|
<MudTh>Logo</MudTh> |
|
<MudTh> |
|
<MudTableSortLabel SortBy="new Func<ChannelViewModel, object>(x => x.Name)">Name</MudTableSortLabel> |
|
</MudTh> |
|
<MudTh>Language</MudTh> |
|
<MudTh>Mode</MudTh> |
|
<MudTh>FFmpeg Profile</MudTh> |
|
<MudTh/> |
|
</HeaderContent> |
|
<RowTemplate> |
|
<MudTd DataLabel="Number">@context.Number</MudTd> |
|
<MudTd DataLabel="Logo"> |
|
@if (!string.IsNullOrWhiteSpace(context.Logo?.Path)) |
|
{ |
|
<MudElement HtmlTag="img" src="@context.Logo.UrlWithContentType" Style="max-height: 50px"/> |
|
} |
|
else |
|
{ |
|
<MudElement HtmlTag="img" src="@($"iptv/logos/gen?text={context.WebEncodedName}")" Style="max-height: 50px"/> |
|
} |
|
</MudTd> |
|
<MudTd DataLabel="Name">@context.Name</MudTd> |
|
<MudTd DataLabel="Language">@context.PreferredAudioLanguageCode</MudTd> |
|
<MudTd DataLabel="Mode">@GetStreamingMode(context.StreamingMode)</MudTd> |
|
<MudTd DataLabel="FFmpeg Profile"> |
|
@if (context.StreamingMode != StreamingMode.HttpLiveStreamingDirect) |
|
{ |
|
@_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId)?.Name |
|
} |
|
</MudTd> |
|
<MudTd> |
|
<div style="align-items: center; display: flex;"> |
|
@if (CanPreviewChannel(context)) |
|
{ |
|
<MudTooltip Text="Preview Channel"> |
|
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle" |
|
OnClick="@(_ => PreviewChannel(context))"> |
|
</MudIconButton> |
|
</MudTooltip> |
|
} |
|
else |
|
{ |
|
<MudTooltip Text="Channel preview requires playout, MPEG-TS/HLS Segmenter, and H264/AAC"> |
|
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle" Disabled="true"> |
|
</MudIconButton> |
|
</MudTooltip> |
|
} |
|
@if (SegmenterService.IsActive(context.Number)) |
|
{ |
|
<MudTooltip Text="Stop Transcode Session"> |
|
<MudIconButton Icon="@Icons.Material.Filled.Stop" |
|
OnClick="@(_ => StopChannel(context))"> |
|
</MudIconButton> |
|
</MudTooltip> |
|
} |
|
else |
|
{ |
|
<div style="width: 48px"></div> |
|
} |
|
<MudTooltip Text="Edit Channel"> |
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" |
|
Href="@($"channels/{context.Id}")"> |
|
</MudIconButton> |
|
</MudTooltip> |
|
<MudTooltip Text="Delete Channel"> |
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" |
|
OnClick="@(_ => DeleteChannelAsync(context))"> |
|
</MudIconButton> |
|
</MudTooltip> |
|
</div> |
|
</MudTd> |
|
</RowTemplate> |
|
<PagerContent> |
|
<MudTablePager/> |
|
</PagerContent> |
|
</MudTable> |
|
</MudContainer> |
|
</div> |
|
</MudForm> |
|
|
|
@code { |
|
private readonly CancellationTokenSource _cts = new(); |
|
|
|
private MudTable<ChannelViewModel> _table; |
|
private List<FFmpegProfileViewModel> _ffmpegProfiles = new(); |
|
|
|
private int _rowsPerPage = 10; |
|
|
|
protected override void OnInitialized() => SegmenterService.OnWorkersChanged += WorkersChanged; |
|
|
|
private void WorkersChanged(object sender, EventArgs e) => |
|
InvokeAsync(StateHasChanged); |
|
|
|
public void Dispose() |
|
{ |
|
SegmenterService.OnWorkersChanged -= WorkersChanged; |
|
|
|
_cts.Cancel(); |
|
_cts.Dispose(); |
|
} |
|
|
|
protected override async Task OnParametersSetAsync() |
|
{ |
|
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), _cts.Token); |
|
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsPageSize), _cts.Token) |
|
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); |
|
} |
|
|
|
private async Task StopChannel(ChannelViewModel channel) => await SegmenterService.StopChannel(channel.Number, _cts.Token); |
|
|
|
private bool CanPreviewChannel(ChannelViewModel channel) |
|
{ |
|
if (channel.ActiveMode is ChannelActiveMode.Inactive) |
|
{ |
|
return false; |
|
} |
|
|
|
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect or StreamingMode.TransportStream) |
|
{ |
|
return false; |
|
} |
|
|
|
if (channel.PlayoutCount < 1) |
|
{ |
|
return false; |
|
} |
|
|
|
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == channel.FFmpegProfileId)); |
|
foreach (FFmpegProfileViewModel profile in maybeProfile) |
|
{ |
|
return profile.VideoFormat is FFmpegProfileVideoFormat.H264 && profile.AudioFormat is FFmpegProfileAudioFormat.Aac; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
private async Task PreviewChannel(ChannelViewModel channel) |
|
{ |
|
if (!CanPreviewChannel(channel)) |
|
{ |
|
return; |
|
} |
|
|
|
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == channel.FFmpegProfileId)); |
|
foreach (FFmpegProfileViewModel profile in maybeProfile) |
|
{ |
|
if (profile.VideoFormat == FFmpegProfileVideoFormat.Hevc) |
|
{ |
|
return; |
|
} |
|
|
|
var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri)); |
|
uri.Path = uri.Path.Replace("/channels", $"/iptv/channel/{channel.Number}.m3u8"); |
|
uri.Query = channel.StreamingMode switch |
|
{ |
|
StreamingMode.HttpLiveStreamingSegmenterV2 => "?mode=segmenter-v2", |
|
_ => "?mode=segmenter" |
|
}; |
|
|
|
if (JwtHelper.IsEnabled) |
|
{ |
|
uri.Query += $"&access_token={JwtHelper.GenerateToken()}"; |
|
} |
|
|
|
var parameters = new DialogParameters { { "StreamUri", uri.ToString() } }; |
|
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge }; |
|
await Dialog.ShowAsync<ChannelPreviewDialog>("Channel Preview", parameters, options); |
|
} |
|
} |
|
|
|
private async Task DeleteChannelAsync(ChannelViewModel channel) |
|
{ |
|
var parameters = new DialogParameters { { "EntityType", "channel" }, { "EntityName", channel.Name } }; |
|
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; |
|
|
|
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Channel", parameters, options); |
|
DialogResult result = await dialog.Result; |
|
if (result is { Canceled: false }) |
|
{ |
|
await Mediator.Send(new DeleteChannel(channel.Id), _cts.Token); |
|
if (_table != null) |
|
{ |
|
await _table.ReloadServerData(); |
|
} |
|
} |
|
} |
|
|
|
private async Task<TableData<ChannelViewModel>> ServerReload(TableState state, CancellationToken cancellationToken) |
|
{ |
|
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsPageSize, state.PageSize.ToString()), _cts.Token); |
|
|
|
List<ChannelViewModel> channels = await Mediator.Send(new GetAllChannels(), _cts.Token); |
|
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)); |
|
|
|
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); |
|
var processedChannels = new List<ChannelViewModel>(); |
|
foreach (ChannelViewModel channel in sorted) |
|
{ |
|
Option<CultureInfo> maybeCultureInfo = allCultures.Find(ci => string.Equals( |
|
ci.ThreeLetterISOLanguageName, |
|
channel.PreferredAudioLanguageCode, |
|
StringComparison.OrdinalIgnoreCase)); |
|
|
|
maybeCultureInfo.Match( |
|
cultureInfo => processedChannels.Add(channel with { PreferredAudioLanguageCode = cultureInfo.EnglishName }), |
|
() => processedChannels.Add(channel)); |
|
} |
|
|
|
// TODO: properly page this data |
|
return new TableData<ChannelViewModel> |
|
{ |
|
TotalItems = channels.Count, |
|
Items = processedChannels.Skip(state.Page * state.PageSize).Take(state.PageSize) |
|
}; |
|
} |
|
|
|
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch |
|
{ |
|
StreamingMode.HttpLiveStreamingDirect => "HLS Direct", |
|
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter", |
|
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2", |
|
StreamingMode.TransportStreamHybrid => "MPEG-TS", |
|
_ => "MPEG-TS (Legacy)" |
|
}; |
|
|
|
} |