Browse Source

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
pull/2391/head
Jason Dove 11 months ago committed by GitHub
parent
commit
5e7da19e5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  3. 2
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  4. 11
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  5. 15
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  6. 2
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  7. 22
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  8. 6
      ErsatzTV.Application/Channels/Mapper.cs
  9. 22
      ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs
  10. 2
      ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs
  11. 9
      ErsatzTV.Application/Channels/Queries/GetChannelByNumberHandler.cs
  12. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  13. 4
      ErsatzTV.Core/Domain/Channel.cs
  14. 7
      ErsatzTV.Core/Domain/ChannelPlayoutSource.cs
  15. 6384
      ErsatzTV.Infrastructure.MySql/Migrations/20250907150553_Add_ChannelPlayoutSource.Designer.cs
  16. 71
      ErsatzTV.Infrastructure.MySql/Migrations/20250907150553_Add_ChannelPlayoutSource.cs
  17. 18
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  18. 6219
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250907150639_Add_ChannelPlayoutSource.Designer.cs
  19. 71
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250907150639_Add_ChannelPlayoutSource.cs
  20. 18
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  21. 6
      ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs
  22. 2
      ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs
  23. 41
      ErsatzTV/Pages/ChannelEditor.razor
  24. 6
      ErsatzTV/ViewModels/ChannelEditViewModel.cs

6
CHANGELOG.md

@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Classic schedules: allow selecting multiple graphics elements on schedule items - 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 ### Fixed
- Fix transcoding content with bt709/pc color metadata - 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) - `local_infile=ON` is required when using MySQL (for bulk inserts when building playouts)
- ETV will set this automatically when it has permission - 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 - 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 ### Changed
- **BREAKING CHANGE**: change how `Scripted Schedule` system works - **BREAKING CHANGE**: change how `Scripted Schedule` system works

2
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -16,7 +16,9 @@ public record ChannelViewModel(
string StreamSelector, string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,
string PreferredAudioTitle, string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode, ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId, int? WatermarkId,
int? FallbackFillerId, int? FallbackFillerId,

2
ErsatzTV.Application/Channels/Commands/CreateChannel.cs

@ -15,7 +15,9 @@ public record CreateChannel(
string StreamSelector, string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,
string PreferredAudioTitle, string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode, ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId, int? WatermarkId,
int? FallbackFillerId, int? FallbackFillerId,

11
ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs

@ -76,7 +76,9 @@ public class CreateChannelHandler(
Group = request.Group, Group = request.Group,
Categories = request.Categories, Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId, FFmpegProfileId = ffmpegProfileId,
PlayoutSource = request.PlayoutSource,
PlayoutMode = request.PlayoutMode, PlayoutMode = request.PlayoutMode,
MirrorSourceChannelId = request.MirrorSourceChannelId,
StreamingMode = request.StreamingMode, StreamingMode = request.StreamingMode,
Artwork = artwork, Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode, StreamSelectorMode = request.StreamSelectorMode,
@ -94,6 +96,15 @@ public class CreateChannelHandler(
ShowInEpg = request.IsEnabled && request.ShowInEpg ShowInEpg = request.IsEnabled && request.ShowInEpg
}; };
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
{
channel.PlayoutMode = ChannelPlayoutMode.Continuous;
}
else
{
channel.MirrorSourceChannelId = null;
}
foreach (int id in watermarkId) foreach (int id in watermarkId)
{ {
channel.WatermarkId = id; channel.WatermarkId = id;

15
ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs

@ -52,8 +52,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml"); string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
string channelNumber = request.ChannelNumber;
int hiddenCount = await dbContext.Channels int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false) .Where(c => c.Number == channelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken); .CountAsync(cancellationToken);
if (hiddenCount > 0) if (hiddenCount > 0)
{ {
@ -97,6 +99,17 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken); string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName); 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 List<Playout> playouts = await dbContext.Playouts
.AsNoTracking() .AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber) .Filter(pi => pi.Channel.Number == request.ChannelNumber)

2
ErsatzTV.Application/Channels/Commands/UpdateChannel.cs

@ -16,7 +16,9 @@ public record UpdateChannel(
string StreamSelector, string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,
string PreferredAudioTitle, string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode, ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId, int? WatermarkId,
int? FallbackFillerId, int? FallbackFillerId,

22
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -33,6 +33,8 @@ public class UpdateChannelHandler(
UpdateChannel update, UpdateChannel update,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name; c.Name = update.Name;
c.Number = update.Number; c.Number = update.Number;
c.Group = update.Group; c.Group = update.Group;
@ -99,10 +101,24 @@ public class UpdateChannelHandler(
} }
} }
c.PlayoutSource = update.PlayoutSource;
c.PlayoutMode = update.PlayoutMode; 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.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId; c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId; c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
searchTargets.SearchTargetsChanged(); searchTargets.SearchTargetsChanged();
@ -119,8 +135,12 @@ public class UpdateChannelHandler(
} }
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken); 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( private static async Task<Validation<BaseError, Channel>> Validate(

6
ErsatzTV.Application/Channels/Mapper.cs

@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Channels;
internal static class Mapper internal static class Mapper
{ {
internal static ChannelViewModel ProjectToViewModel(Channel channel) => internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
new( new(
channel.Id, channel.Id,
channel.Number, channel.Number,
@ -19,11 +19,13 @@ internal static class Mapper
channel.StreamSelector, channel.StreamSelector,
channel.PreferredAudioLanguageCode, channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle, channel.PreferredAudioTitle,
channel.PlayoutSource,
channel.PlayoutMode, channel.PlayoutMode,
channel.MirrorSourceChannelId,
channel.StreamingMode, channel.StreamingMode,
channel.WatermarkId, channel.WatermarkId,
channel.FallbackFillerId, channel.FallbackFillerId,
channel.Playouts?.Count ?? 0, playoutCount,
channel.PreferredSubtitleLanguageCode, channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode, channel.SubtitleMode,
channel.MusicVideoCreditsMode, channel.MusicVideoCreditsMode,

22
ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper; using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
@ -8,5 +9,22 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
{ {
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) => public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
await channelRepository.GetAll(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;
}
} }

2
ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs

@ -8,5 +8,5 @@ public class GetChannelByIdHandler(IChannelRepository channelRepository)
{ {
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) => public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id) channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel); .MapT(c => ProjectToViewModel(c, 0));
} }

9
ErsatzTV.Application/Channels/Queries/GetChannelByNumberHandler.cs

@ -3,12 +3,9 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels; 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) => public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel); channelRepository.GetByNumber(request.ChannelNumber).MapT(c => ProjectToViewModel(c, 0));
} }

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -183,7 +183,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata) .ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Include(i => i.Watermarks) .Include(i => i.Watermarks)
.ForChannelAndTime(channel.Id, now) .ForChannelAndTime(channel.MirrorSourceChannelId ?? channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem())) .Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item, cancellationToken)); .BindT(item => ValidatePlayoutItemPath(dbContext, item, cancellationToken));

4
ErsatzTV.Core/Domain/Channel.cs

@ -32,7 +32,11 @@ public class Channel
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; } public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
public string MusicVideoCreditsTemplate { get; set; } public string MusicVideoCreditsTemplate { get; set; }
public ChannelSongVideoMode SongVideoMode { get; set; } public ChannelSongVideoMode SongVideoMode { get; set; }
public ChannelPlayoutSource PlayoutSource { get; set; }
public ChannelPlayoutMode PlayoutMode { 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 ChannelTranscodeMode TranscodeMode { get; set; }
public ChannelIdleBehavior IdleBehavior { get; set; } public ChannelIdleBehavior IdleBehavior { get; set; }
public bool IsEnabled { get; set; } public bool IsEnabled { get; set; }

7
ErsatzTV.Core/Domain/ChannelPlayoutSource.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelPlayoutSource
{
Generated = 0,
Mirror = 1
}

6384
ErsatzTV.Infrastructure.MySql/Migrations/20250907150553_Add_ChannelPlayoutSource.Designer.cs generated

File diff suppressed because it is too large Load Diff

71
ErsatzTV.Infrastructure.MySql/Migrations/20250907150553_Add_ChannelPlayoutSource.cs

@ -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");
}
}
}

18
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -277,6 +277,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<int?>("MirrorSourceChannelId")
.HasColumnType("int");
b.Property<int>("MusicVideoCreditsMode") b.Property<int>("MusicVideoCreditsMode")
.HasColumnType("int"); .HasColumnType("int");
@ -292,6 +295,12 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("PlayoutMode") b.Property<int>("PlayoutMode")
.HasColumnType("int"); .HasColumnType("int");
b.Property<TimeSpan?>("PlayoutOffset")
.HasColumnType("time(6)");
b.Property<int>("PlayoutSource")
.HasColumnType("int");
b.Property<string>("PreferredAudioLanguageCode") b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("longtext"); .HasColumnType("longtext");
@ -336,6 +345,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("FallbackFillerId"); b.HasIndex("FallbackFillerId");
b.HasIndex("MirrorSourceChannelId");
b.HasIndex("Number") b.HasIndex("Number")
.IsUnique(); .IsUnique();
@ -4048,6 +4059,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("FallbackFillerId") .HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Channel", "MirrorSourceChannel")
.WithMany()
.HasForeignKey("MirrorSourceChannelId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark") b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany() .WithMany()
.HasForeignKey("WatermarkId") .HasForeignKey("WatermarkId")
@ -4057,6 +4073,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("FallbackFiller"); b.Navigation("FallbackFiller");
b.Navigation("MirrorSourceChannel");
b.Navigation("Watermark"); b.Navigation("Watermark");
}); });

6219
ErsatzTV.Infrastructure.Sqlite/Migrations/20250907150639_Add_ChannelPlayoutSource.Designer.cs generated

File diff suppressed because it is too large Load Diff

71
ErsatzTV.Infrastructure.Sqlite/Migrations/20250907150639_Add_ChannelPlayoutSource.cs

@ -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");
}
}
}

18
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -264,6 +264,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<int?>("MirrorSourceChannelId")
.HasColumnType("INTEGER");
b.Property<int>("MusicVideoCreditsMode") b.Property<int>("MusicVideoCreditsMode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -279,6 +282,12 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("PlayoutMode") b.Property<int>("PlayoutMode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<TimeSpan?>("PlayoutOffset")
.HasColumnType("TEXT");
b.Property<int>("PlayoutSource")
.HasColumnType("INTEGER");
b.Property<string>("PreferredAudioLanguageCode") b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -323,6 +332,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("FallbackFillerId"); b.HasIndex("FallbackFillerId");
b.HasIndex("MirrorSourceChannelId");
b.HasIndex("Number") b.HasIndex("Number")
.IsUnique(); .IsUnique();
@ -3883,6 +3894,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("FallbackFillerId") .HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Channel", "MirrorSourceChannel")
.WithMany()
.HasForeignKey("MirrorSourceChannelId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark") b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany() .WithMany()
.HasForeignKey("WatermarkId") .HasForeignKey("WatermarkId")
@ -3892,6 +3908,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("FallbackFiller"); b.Navigation("FallbackFiller");
b.Navigation("MirrorSourceChannel");
b.Navigation("Watermark"); b.Navigation("Watermark");
}); });

6
ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs

@ -43,5 +43,11 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
builder.Property(c => c.Group) builder.Property(c => c.Group)
.IsRequired() .IsRequired()
.HasDefaultValue("ErsatzTV"); .HasDefaultValue("ErsatzTV");
builder.HasOne(i => i.MirrorSourceChannel)
.WithMany()
.HasForeignKey(i => i.MirrorSourceChannelId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
} }
} }

2
ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs

@ -45,6 +45,8 @@ public class ChannelRepository : IChannelRepository
.Include(c => c.FFmpegProfile) .Include(c => c.FFmpegProfile)
.Include(c => c.Artwork) .Include(c => c.Artwork)
.Include(c => c.Playouts) .Include(c => c.Playouts)
.Include(c => c.MirrorSourceChannel)
.ThenInclude(mc => mc.Playouts)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }

41
ErsatzTV/Pages/ChannelEditor.razor

@ -1,5 +1,6 @@
@page "/channels/{Id:int?}" @page "/channels/{Id:int?}"
@page "/channels/add" @page "/channels/add"
@using System.Globalization
@using ErsatzTV.Application.Artworks @using ErsatzTV.Application.Artworks
@using ErsatzTV.Application.Channels @using ErsatzTV.Application.Channels
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@ -69,13 +70,41 @@
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Playout Mode</MudText> <MudText>Playout Source</MudText>
</div> </div>
<MudSelect @bind-Value="_model.PlayoutMode" For="@(() => _model.PlayoutMode)" HelperText="Controls the progression of the channel's playout"> <MudSelect @bind-Value="_model.PlayoutSource" For="@(() => _model.PlayoutSource)" HelperText="Controls the source of the channel's content">
<MudSelectItem Value="@(ChannelPlayoutMode.Continuous)">Continuous</MudSelectItem> <MudSelectItem Value="@(ChannelPlayoutSource.Generated)">Generated</MudSelectItem>
<MudSelectItem Value="@(ChannelPlayoutMode.OnDemand)">On Demand</MudSelectItem> <MudSelectItem Value="@(ChannelPlayoutSource.Mirror)">Mirror</MudSelectItem>
</MudSelect> </MudSelect>
</MudStack> </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"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Is Enabled</MudText> <MudText>Is Enabled</MudText>
@ -285,6 +314,7 @@ else
private readonly ChannelEditViewModelValidator _validator = new(); private readonly ChannelEditViewModelValidator _validator = new();
private MudForm _form; private MudForm _form;
private List<ChannelViewModel> _channels = [];
private List<FFmpegProfileViewModel> _ffmpegProfiles = []; private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private List<LanguageCodeViewModel> _availableCultures = []; private List<LanguageCodeViewModel> _availableCultures = [];
private List<WatermarkViewModel> _watermarks = []; private List<WatermarkViewModel> _watermarks = [];
@ -309,6 +339,7 @@ else
{ {
var loadingTasks = new List<Task> 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 GetAllFFmpegProfiles(), token), r => _ffmpegProfiles = r),
LoadDataAsync(() => Mediator.Send(new GetAllLanguageCodes(), token), r => _availableCultures = r), LoadDataAsync(() => Mediator.Send(new GetAllLanguageCodes(), token), r => _availableCultures = r),
LoadDataAsync(() => Mediator.Send(new GetAllWatermarks(), token), r => _watermarks = r), LoadDataAsync(() => Mediator.Send(new GetAllWatermarks(), token), r => _watermarks = r),
@ -342,7 +373,9 @@ else
_model.Logo = channelViewModel.Logo; _model.Logo = channelViewModel.Logo;
} }
_model.PlayoutSource = channelViewModel.PlayoutSource;
_model.PlayoutMode = channelViewModel.PlayoutMode; _model.PlayoutMode = channelViewModel.PlayoutMode;
_model.MirrorSourceChannelId = channelViewModel.MirrorSourceChannelId;
_model.StreamingMode = channelViewModel.StreamingMode; _model.StreamingMode = channelViewModel.StreamingMode;
_model.StreamSelectorMode = channelViewModel.StreamSelectorMode; _model.StreamSelectorMode = channelViewModel.StreamSelectorMode;
_model.StreamSelector = channelViewModel.StreamSelector; _model.StreamSelector = channelViewModel.StreamSelector;

6
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -19,7 +19,9 @@ public class ChannelEditViewModel
public string PreferredAudioTitle { get; set; } public string PreferredAudioTitle { get; set; }
public ArtworkContentTypeModel Logo { get; set; } public ArtworkContentTypeModel Logo { get; set; }
public string ExternalLogoUrl { get; set; } public string ExternalLogoUrl { get; set; }
public ChannelPlayoutSource PlayoutSource { get; set; }
public ChannelPlayoutMode PlayoutMode { get; set; } public ChannelPlayoutMode PlayoutMode { get; set; }
public int? MirrorSourceChannelId { get; set; }
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
public int? WatermarkId { get; set; } public int? WatermarkId { get; set; }
public int? FallbackFillerId { get; set; } public int? FallbackFillerId { get; set; }
@ -56,7 +58,9 @@ public class ChannelEditViewModel
StreamSelector, StreamSelector,
PreferredAudioLanguageCode, PreferredAudioLanguageCode,
PreferredAudioTitle, PreferredAudioTitle,
PlayoutSource,
PlayoutMode, PlayoutMode,
MirrorSourceChannelId,
StreamingMode, StreamingMode,
WatermarkId, WatermarkId,
FallbackFillerId, FallbackFillerId,
@ -84,7 +88,9 @@ public class ChannelEditViewModel
StreamSelector, StreamSelector,
PreferredAudioLanguageCode, PreferredAudioLanguageCode,
PreferredAudioTitle, PreferredAudioTitle,
PlayoutSource,
PlayoutMode, PlayoutMode,
MirrorSourceChannelId,
StreamingMode, StreamingMode,
WatermarkId, WatermarkId,
FallbackFillerId, FallbackFillerId,

Loading…
Cancel
Save