add channel mirror (#2390)

* add channel playout source (doesn't do anything yet)

* configure mirror channel

* fix mirror playback

* sync epg for mirror channel

* update changelog
This commit is contained in:
Jason Dove
2025-09-07 16:00:27 +00:00
committed by GitHub
parent e25b669cc4
commit 5e7da19e5e
24 changed files with 12929 additions and 19 deletions
+5 -1
View File
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Classic schedules: allow selecting multiple graphics elements on schedule items
- Add channel `Playout Source` setting
- `Generated`: default/existing behavior where channel must have its own playout
- `Mirror`: channel will play content from the specified `Mirror Source Channel`'s playout
- This allows the exact same content on different channels with different channel settings
### Fixed
- Fix transcoding content with bt709/pc color metadata
@@ -15,7 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `local_infile=ON` is required when using MySQL (for bulk inserts when building playouts)
- ETV will set this automatically when it has permission
- When ETV does not have permission, startup will fail with logged instructions on how to configure MySql
- Fix scaling content in locales that don't use period as a decimal separator (e.g. `,`)
- Fix scaling anamorphic content in locales that don't use period as a decimal separator (e.g. `,`)
### Changed
- **BREAKING CHANGE**: change how `Scripted Schedule` system works
@@ -16,7 +16,9 @@ public record ChannelViewModel(
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
@@ -15,7 +15,9 @@ public record CreateChannel(
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
@@ -76,7 +76,9 @@ public class CreateChannelHandler(
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
PlayoutSource = request.PlayoutSource,
PlayoutMode = request.PlayoutMode,
MirrorSourceChannelId = request.MirrorSourceChannelId,
StreamingMode = request.StreamingMode,
Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode,
@@ -94,6 +96,15 @@ public class CreateChannelHandler(
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
{
channel.PlayoutMode = ChannelPlayoutMode.Continuous;
}
else
{
channel.MirrorSourceChannelId = null;
}
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
@@ -52,8 +52,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
string channelNumber = request.ChannelNumber;
int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
.Where(c => c.Number == channelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken);
if (hiddenCount > 0)
{
@@ -97,6 +99,17 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
Option<string> maybeMirrorNumber = await dbContext.Channels
.Filter(c => c.Number == channelNumber)
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
.Map(c => c.MirrorSourceChannel.Number)
.ToListAsync(cancellationToken)
.Map(list => list.HeadOrNone());
foreach (string mirrorNumber in maybeMirrorNumber)
{
request = new RefreshChannelData(ChannelNumber: mirrorNumber);
}
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
@@ -16,7 +16,9 @@ public record UpdateChannel(
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
@@ -33,6 +33,8 @@ public class UpdateChannelHandler(
UpdateChannel update,
CancellationToken cancellationToken)
{
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name;
c.Number = update.Number;
c.Group = update.Group;
@@ -99,10 +101,24 @@ public class UpdateChannelHandler(
}
}
c.PlayoutSource = update.PlayoutSource;
c.PlayoutMode = update.PlayoutMode;
if (c.PlayoutSource is ChannelPlayoutSource.Mirror)
{
c.PlayoutMode = ChannelPlayoutMode.Continuous;
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
}
else
{
c.MirrorSourceChannelId = null;
}
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
@@ -119,8 +135,12 @@ public class UpdateChannelHandler(
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
if (hasEpgChange)
{
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
}
return ProjectToViewModel(c);
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
}
private static async Task<Validation<BaseError, Channel>> Validate(
+4 -2
View File
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Channels;
internal static class Mapper
{
internal static ChannelViewModel ProjectToViewModel(Channel channel) =>
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
new(
channel.Id,
channel.Number,
@@ -19,11 +19,13 @@ internal static class Mapper
channel.StreamSelector,
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.PlayoutSource,
channel.PlayoutMode,
channel.MirrorSourceChannelId,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0,
playoutCount,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
@@ -8,5 +9,22 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
{
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
await channelRepository.GetAll(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
.Map(list => list.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
private static int GetPlayoutsCount(Channel channel)
{
var result = 0;
if (channel.Playouts != null)
{
result += channel.Playouts.Count;
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
{
result += channel.MirrorSourceChannel.Playouts.Count;
}
return result;
}
}
@@ -8,5 +8,5 @@ public class GetChannelByIdHandler(IChannelRepository channelRepository)
{
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel);
.MapT(c => ProjectToViewModel(c, 0));
}
@@ -3,12 +3,9 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByNumberHandler : IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
public class GetChannelByNumberHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByNumberHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel);
channelRepository.GetByNumber(request.ChannelNumber).MapT(c => ProjectToViewModel(c, 0));
}
@@ -183,7 +183,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Include(i => i.Watermarks)
.ForChannelAndTime(channel.Id, now)
.ForChannelAndTime(channel.MirrorSourceChannelId ?? channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item, cancellationToken));
+4
View File
@@ -32,7 +32,11 @@ public class Channel
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
public string MusicVideoCreditsTemplate { get; set; }
public ChannelSongVideoMode SongVideoMode { get; set; }
public ChannelPlayoutSource PlayoutSource { get; set; }
public ChannelPlayoutMode PlayoutMode { get; set; }
public int? MirrorSourceChannelId { get; set; }
public Channel MirrorSourceChannel { get; set; }
public TimeSpan? PlayoutOffset { get; set; }
public ChannelTranscodeMode TranscodeMode { get; set; }
public ChannelIdleBehavior IdleBehavior { get; set; }
public bool IsEnabled { get; set; }
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelPlayoutSource
{
Generated = 0,
Mirror = 1
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelPlayoutSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "MirrorSourceChannelId",
table: "Channel",
type: "int",
nullable: true);
migrationBuilder.AddColumn<TimeSpan>(
name: "PlayoutOffset",
table: "Channel",
type: "time(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PlayoutSource",
table: "Channel",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_Channel_MirrorSourceChannelId",
table: "Channel",
column: "MirrorSourceChannelId");
migrationBuilder.AddForeignKey(
name: "FK_Channel_Channel_MirrorSourceChannelId",
table: "Channel",
column: "MirrorSourceChannelId",
principalTable: "Channel",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Channel_Channel_MirrorSourceChannelId",
table: "Channel");
migrationBuilder.DropIndex(
name: "IX_Channel_MirrorSourceChannelId",
table: "Channel");
migrationBuilder.DropColumn(
name: "MirrorSourceChannelId",
table: "Channel");
migrationBuilder.DropColumn(
name: "PlayoutOffset",
table: "Channel");
migrationBuilder.DropColumn(
name: "PlayoutSource",
table: "Channel");
}
}
}
@@ -277,6 +277,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<int?>("MirrorSourceChannelId")
.HasColumnType("int");
b.Property<int>("MusicVideoCreditsMode")
.HasColumnType("int");
@@ -292,6 +295,12 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("PlayoutMode")
.HasColumnType("int");
b.Property<TimeSpan?>("PlayoutOffset")
.HasColumnType("time(6)");
b.Property<int>("PlayoutSource")
.HasColumnType("int");
b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("longtext");
@@ -336,6 +345,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("FallbackFillerId");
b.HasIndex("MirrorSourceChannelId");
b.HasIndex("Number")
.IsUnique();
@@ -4048,6 +4059,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Channel", "MirrorSourceChannel")
.WithMany()
.HasForeignKey("MirrorSourceChannelId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
@@ -4057,6 +4073,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("FallbackFiller");
b.Navigation("MirrorSourceChannel");
b.Navigation("Watermark");
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelPlayoutSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "MirrorSourceChannelId",
table: "Channel",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<TimeSpan>(
name: "PlayoutOffset",
table: "Channel",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PlayoutSource",
table: "Channel",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_Channel_MirrorSourceChannelId",
table: "Channel",
column: "MirrorSourceChannelId");
migrationBuilder.AddForeignKey(
name: "FK_Channel_Channel_MirrorSourceChannelId",
table: "Channel",
column: "MirrorSourceChannelId",
principalTable: "Channel",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Channel_Channel_MirrorSourceChannelId",
table: "Channel");
migrationBuilder.DropIndex(
name: "IX_Channel_MirrorSourceChannelId",
table: "Channel");
migrationBuilder.DropColumn(
name: "MirrorSourceChannelId",
table: "Channel");
migrationBuilder.DropColumn(
name: "PlayoutOffset",
table: "Channel");
migrationBuilder.DropColumn(
name: "PlayoutSource",
table: "Channel");
}
}
}
@@ -264,6 +264,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<int?>("MirrorSourceChannelId")
.HasColumnType("INTEGER");
b.Property<int>("MusicVideoCreditsMode")
.HasColumnType("INTEGER");
@@ -279,6 +282,12 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("PlayoutMode")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("PlayoutOffset")
.HasColumnType("TEXT");
b.Property<int>("PlayoutSource")
.HasColumnType("INTEGER");
b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("TEXT");
@@ -323,6 +332,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("FallbackFillerId");
b.HasIndex("MirrorSourceChannelId");
b.HasIndex("Number")
.IsUnique();
@@ -3883,6 +3894,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Channel", "MirrorSourceChannel")
.WithMany()
.HasForeignKey("MirrorSourceChannelId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
@@ -3892,6 +3908,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("FallbackFiller");
b.Navigation("MirrorSourceChannel");
b.Navigation("Watermark");
});
@@ -43,5 +43,11 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
builder.Property(c => c.Group)
.IsRequired()
.HasDefaultValue("ErsatzTV");
builder.HasOne(i => i.MirrorSourceChannel)
.WithMany()
.HasForeignKey(i => i.MirrorSourceChannelId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
}
}
@@ -45,6 +45,8 @@ public class ChannelRepository : IChannelRepository
.Include(c => c.FFmpegProfile)
.Include(c => c.Artwork)
.Include(c => c.Playouts)
.Include(c => c.MirrorSourceChannel)
.ThenInclude(mc => mc.Playouts)
.ToListAsync(cancellationToken);
}
+37 -4
View File
@@ -1,5 +1,6 @@
@page "/channels/{Id:int?}"
@page "/channels/add"
@using System.Globalization
@using ErsatzTV.Application.Artworks
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.FFmpegProfiles
@@ -69,13 +70,41 @@
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playout Mode</MudText>
<MudText>Playout Source</MudText>
</div>
<MudSelect @bind-Value="_model.PlayoutMode" For="@(() => _model.PlayoutMode)" HelperText="Controls the progression of the channel's playout">
<MudSelectItem Value="@(ChannelPlayoutMode.Continuous)">Continuous</MudSelectItem>
<MudSelectItem Value="@(ChannelPlayoutMode.OnDemand)">On Demand</MudSelectItem>
<MudSelect @bind-Value="_model.PlayoutSource" For="@(() => _model.PlayoutSource)" HelperText="Controls the source of the channel's content">
<MudSelectItem Value="@(ChannelPlayoutSource.Generated)">Generated</MudSelectItem>
<MudSelectItem Value="@(ChannelPlayoutSource.Mirror)">Mirror</MudSelectItem>
</MudSelect>
</MudStack>
@if (_model.PlayoutSource is ChannelPlayoutSource.Mirror)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Mirror Source Channel</MudText>
</div>
<MudSelect @bind-Value="_model.MirrorSourceChannelId" For="@(() => _model.MirrorSourceChannelId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (ChannelViewModel channel in _channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)))
{
<MudSelectItem T="int?" Value="@channel.Id">(@channel.Number) - @channel.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
else
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playout Mode</MudText>
</div>
<MudSelect @bind-Value="_model.PlayoutMode" For="@(() => _model.PlayoutMode)" HelperText="Controls the progression of the channel's playout">
<MudSelectItem Value="@(ChannelPlayoutMode.Continuous)">Continuous</MudSelectItem>
<MudSelectItem Value="@(ChannelPlayoutMode.OnDemand)">On Demand</MudSelectItem>
</MudSelect>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Is Enabled</MudText>
@@ -285,6 +314,7 @@ else
private readonly ChannelEditViewModelValidator _validator = new();
private MudForm _form;
private List<ChannelViewModel> _channels = [];
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private List<LanguageCodeViewModel> _availableCultures = [];
private List<WatermarkViewModel> _watermarks = [];
@@ -309,6 +339,7 @@ else
{
var loadingTasks = new List<Task>
{
LoadDataAsync(() => Mediator.Send(new GetAllChannels(), token), r => _channels = r),
LoadDataAsync(() => Mediator.Send(new GetAllFFmpegProfiles(), token), r => _ffmpegProfiles = r),
LoadDataAsync(() => Mediator.Send(new GetAllLanguageCodes(), token), r => _availableCultures = r),
LoadDataAsync(() => Mediator.Send(new GetAllWatermarks(), token), r => _watermarks = r),
@@ -342,7 +373,9 @@ else
_model.Logo = channelViewModel.Logo;
}
_model.PlayoutSource = channelViewModel.PlayoutSource;
_model.PlayoutMode = channelViewModel.PlayoutMode;
_model.MirrorSourceChannelId = channelViewModel.MirrorSourceChannelId;
_model.StreamingMode = channelViewModel.StreamingMode;
_model.StreamSelectorMode = channelViewModel.StreamSelectorMode;
_model.StreamSelector = channelViewModel.StreamSelector;
@@ -19,7 +19,9 @@ public class ChannelEditViewModel
public string PreferredAudioTitle { get; set; }
public ArtworkContentTypeModel Logo { get; set; }
public string ExternalLogoUrl { get; set; }
public ChannelPlayoutSource PlayoutSource { get; set; }
public ChannelPlayoutMode PlayoutMode { get; set; }
public int? MirrorSourceChannelId { get; set; }
public StreamingMode StreamingMode { get; set; }
public int? WatermarkId { get; set; }
public int? FallbackFillerId { get; set; }
@@ -56,7 +58,9 @@ public class ChannelEditViewModel
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
MirrorSourceChannelId,
StreamingMode,
WatermarkId,
FallbackFillerId,
@@ -84,7 +88,9 @@ public class ChannelEditViewModel
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
MirrorSourceChannelId,
StreamingMode,
WatermarkId,
FallbackFillerId,