Browse Source

support multiple watermarks in yaml schedules (#2267)

* add multiple watermarks per playout item

* fixes

* update yaml playout watermark to support multiple watermarks

* use graphics engine for intermittent watermarks
pull/2269/head
Jason Dove 4 days ago committed by GitHub
parent
commit
5a5c049835
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 8
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  3. 1
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  4. 20
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  5. 8
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  6. 2
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  7. 7
      ErsatzTV.Core/Domain/PlayoutItem.cs
  8. 9
      ErsatzTV.Core/Domain/PlayoutItemWatermark.cs
  9. 163
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  10. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  11. 11
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  12. 7
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
  13. 7
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs
  14. 7
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs
  15. 7
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs
  16. 12
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs
  17. 14
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs
  18. 12
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs
  19. 41
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs
  20. 35
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs
  21. 6223
      ErsatzTV.Infrastructure.MySql/Migrations/20250806191133_Add_PlayoutItemWatermark.Designer.cs
  22. 51
      ErsatzTV.Infrastructure.MySql/Migrations/20250806191133_Add_PlayoutItemWatermark.cs
  23. 6223
      ErsatzTV.Infrastructure.MySql/Migrations/20250806191615_Populate_PlayoutItemWatermark.Designer.cs
  24. 23
      ErsatzTV.Infrastructure.MySql/Migrations/20250806191615_Populate_PlayoutItemWatermark.cs
  25. 6211
      ErsatzTV.Infrastructure.MySql/Migrations/20250806195038_Remove_PlayoutItemWatermark.Designer.cs
  26. 49
      ErsatzTV.Infrastructure.MySql/Migrations/20250806195038_Remove_PlayoutItemWatermark.cs
  27. 50
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  28. 6060
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191226_Add_PlayoutItemWatermarkTable.Designer.cs
  29. 50
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191226_Add_PlayoutItemWatermarkTable.cs
  30. 6060
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191314_Populate_PlayoutItemWatermark.Designer.cs
  31. 23
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191314_Populate_PlayoutItemWatermark.cs
  32. 6048
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806195120_Remove_PlayoutItemWatermark.Designer.cs
  33. 49
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806195120_Remove_PlayoutItemWatermark.cs
  34. 50
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  35. 17
      ErsatzTV.Infrastructure/Data/Configurations/PlayoutItemConfiguration.cs
  36. 2
      ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs
  37. 17
      ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs
  38. 4
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  39. 18
      ErsatzTV/Pages/YamlValidator.razor
  40. 15
      ErsatzTV/Resources/yaml-playout-import.schema.json
  41. 15
      ErsatzTV/Resources/yaml-playout.schema.json

11
CHANGELOG.md

@ -6,9 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -6,9 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add *experimental* graphics engine
- `Permanent` watermarks will use new graphics engine
- `Opacity Expression` watermarks will use new graphics engine
- `Intermittent` watermarks will still use normal overlay pipeline (for now)
- All watermarks will use new graphics engine
- Add `Opacity Expression` watermark mode
- This allows specifying an expression that returns an opacity between 0.0 and 1.0
- The expression can use:
@ -16,6 +14,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -16,6 +14,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
- `time_of_day_seconds` - the total number of seconds the frame is since midnight
### Changed
- Allow multiple watermarks on a single playout item
- YAML playout: `watermark` instruction changes:
- When value is `true`, will add named watermark to list of active watermarks
- When value is `false` and `name` is specified, will remove named watermark from list of active watermarks
- When value is `false` and `name` is not specified, will clear all active watermarks
## [25.4.0] - 2025-08-05
### Added
- Add `Troubleshoot Playback` to overflow menu on all media cards

8
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -166,6 +166,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -166,6 +166,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.Include(p => p.Channel)
.Include(p => p.Deco)
.Include(p => p.Items)
.ThenInclude(pi => pi.Watermarks)
.Include(p => p.PlayoutHistory)
.Include(p => p.Templates)
.ThenInclude(t => t.Template)
@ -181,6 +182,10 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -181,6 +182,10 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Watermark)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
@ -212,6 +217,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -212,6 +217,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.ThenInclude(a => a.MediaItem)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Watermark)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)

1
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -467,6 +467,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -467,6 +467,7 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach (var graphicsEngineContext in processModel.GraphicsEngineContext)
{
var pipe = new Pipe();
maybePipe = pipe;
processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(pipe.Reader.AsStream()));
// fire and forget graphics engine task

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

@ -164,7 +164,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -164,7 +164,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(i => i.Watermark)
.Include(i => i.Watermarks)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
@ -173,7 +173,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -173,7 +173,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Include(i => i.Watermark)
.Include(i => i.Watermarks)
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item));
@ -242,7 +242,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -242,7 +242,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.BindT(watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
Option<ChannelWatermark> playoutItemWatermark = Optional(playoutItemWithPath.PlayoutItem.Watermark);
List<ChannelWatermark> playoutItemWatermarks = [];
playoutItemWatermarks.AddRange(playoutItemWithPath.PlayoutItem.Watermarks);
bool disableWatermarks = playoutItemWithPath.PlayoutItem.DisableWatermarks;
WatermarkResult watermarkResult = GetPlayoutItemWatermark(playoutItemWithPath.PlayoutItem, now);
switch (watermarkResult)
@ -254,7 +256,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -254,7 +256,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
disableWatermarks = true;
break;
case CustomWatermark watermark:
playoutItemWatermark = watermark.Watermark;
playoutItemWatermarks.Clear();
playoutItemWatermarks.Add(watermark.Watermark);
break;
}
@ -263,7 +266,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -263,7 +266,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
playoutItemWatermark,
playoutItemWatermarks.HeadOrNone(),
maybeGlobalWatermark,
ffmpegPath,
ffprobePath,
@ -278,7 +281,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -278,7 +281,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png";
disableWatermarks = false;
playoutItemWatermark = new ChannelWatermark
playoutItemWatermarks.Clear();
playoutItemWatermarks.Add(new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
Size = WatermarkSize.Scaled,
@ -289,7 +293,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -289,7 +293,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Location = WatermarkLocation.TopLeft,
ImageSource = ChannelWatermarkImageSource.Resource,
Image = image
};
});
}
}
@ -335,7 +339,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -335,7 +339,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
start,
finish,
effectiveNow,
playoutItemWatermark,
playoutItemWatermarks,
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,

8
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -78,11 +78,11 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -78,11 +78,11 @@ public class PrepareTroubleshootingPlaybackHandler(
return BaseError.New("Media item does not exist on disk");
}
Option<ChannelWatermark> maybeWatermark = Option<ChannelWatermark>.None;
List<ChannelWatermark> watermarks = [];
if (request.WatermarkId > 0)
{
maybeWatermark = await dbContext.ChannelWatermarks
.SelectOneAsync(cw => cw.Id, cw => cw.Id == request.WatermarkId);
watermarks.AddRange(await dbContext.ChannelWatermarks
.SelectOneAsync(cw => cw.Id, cw => cw.Id == request.WatermarkId));
}
DateTimeOffset now = DateTimeOffset.Now;
@ -132,7 +132,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -132,7 +132,7 @@ public class PrepareTroubleshootingPlaybackHandler(
now,
now + duration,
now,
maybeWatermark,
watermarks,
Option<ChannelWatermark>.None,
ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver,

2
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -20,6 +20,8 @@ public class ChannelWatermark @@ -20,6 +20,8 @@ public class ChannelWatermark
public int Opacity { get; set; }
public bool PlaceWithinSourceContent { get; set; }
public string OpacityExpression { get; set; }
public List<PlayoutItem> PlayoutItems { get; set; }
public List<PlayoutItemWatermark> PlayoutItemWatermarks { get; set; }
}
public enum ChannelWatermarkMode

7
ErsatzTV.Core/Domain/PlayoutItem.cs

@ -22,8 +22,7 @@ public class PlayoutItem @@ -22,8 +22,7 @@ public class PlayoutItem
public TimeSpan InPoint { get; set; }
public TimeSpan OutPoint { get; set; }
public string ChapterTitle { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
public List<ChannelWatermark> Watermarks { get; set; }
public bool DisableWatermarks { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; }
@ -32,6 +31,7 @@ public class PlayoutItem @@ -32,6 +31,7 @@ public class PlayoutItem
public string BlockKey { get; set; }
public string CollectionKey { get; set; }
public string CollectionEtag { get; set; }
public List<PlayoutItemWatermark> PlayoutItemWatermarks { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
@ -55,8 +55,7 @@ public class PlayoutItem @@ -55,8 +55,7 @@ public class PlayoutItem
InPoint = chapter.StartTime,
OutPoint = chapter.EndTime,
ChapterTitle = chapter.Title,
Watermark = Watermark,
WatermarkId = WatermarkId,
Watermarks = Watermarks,
DisableWatermarks = DisableWatermarks,
PreferredAudioLanguageCode = PreferredAudioLanguageCode,
PreferredAudioTitle = PreferredAudioTitle,

9
ErsatzTV.Core/Domain/PlayoutItemWatermark.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class PlayoutItemWatermark
{
public int PlayoutItemId { get; set; }
public PlayoutItem PlayoutItem { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
}

163
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -62,7 +62,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -62,7 +62,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark,
List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark,
string vaapiDisplay,
VaapiDriver vaapiDriver,
@ -157,30 +157,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -157,30 +157,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
}
}
Option<WatermarkOptions> watermarkOptions = disableWatermarks
? None
: await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
None,
None);
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
.Map(o => o.Watermark)
.Flatten()
.Where(wm => wm.Mode == ChannelWatermarkMode.Intermittent)
.Map(wm =>
WatermarkCalculator.CalculateFadePoints(
start,
inPoint,
outPoint,
playbackSettings.StreamSeek,
wm.FrequencyMinutes,
wm.DurationSeconds));
string audioFormat = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Aac => AudioFormat.Aac,
@ -345,31 +321,67 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -345,31 +321,67 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
method);
}).Flatten();
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
// use graphics engine for permanent watermarks, or opacity expressions
var maybeGraphicsEngineWatermark = watermarkOptions
.Where(o => o.Watermark
.Map(wm => wm.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.OpacityExpression)
.IfNone(false));
foreach (var options in maybeGraphicsEngineWatermark)
// use graphics engine for all watermarks
if (!disableWatermarks)
{
watermarkInputFile = Option<WatermarkInputFile>.None;
var watermarks = new Dictionary<int, WatermarkElementContext>();
// still need channel and global watermarks
if (playoutItemWatermarks.Count == 0)
{
WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
Option<ChannelWatermark>.None,
globalWatermark,
videoVersion,
None,
None);
graphicsEngineInput = new GraphicsEngineInput();
foreach (var watermark in options.Watermark)
{
// don't allow duplicates
watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options));
}
}
// load all playout item watermarks
foreach (var playoutItemWatermark in playoutItemWatermarks)
{
WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
None,
None);
WatermarkElementContext watermark = new WatermarkElementContext(options);
foreach (var watermark in options.Watermark)
{
// don't allow duplicates
watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options));
}
}
graphicsEngineContext = new GraphicsEngineContext(
[watermark],
channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24),
ChannelStartTime: channelStartTime,
ContentStartTime: start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now);
// only use graphics engine when we have watermarks
if (watermarks.Count > 0)
{
graphicsEngineInput = new GraphicsEngineInput();
graphicsEngineContext = new GraphicsEngineContext(
watermarks.Values.OfType<GraphicsElementContext>().ToList(),
channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24),
ChannelStartTime: channelStartTime,
ContentStartTime: start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now);
}
}
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind);
@ -1014,71 +1026,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1014,71 +1026,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false);
}
private static Option<WatermarkInputFile> GetWatermarkInputFile(
Option<WatermarkOptions> watermarkOptions,
Option<List<FadePoint>> maybeFadePoints)
{
foreach (WatermarkOptions options in watermarkOptions)
{
foreach (ChannelWatermark watermark in options.Watermark)
{
// skip watermark if intermittent and no fade points
if (watermark.Mode != ChannelWatermarkMode.None &&
(watermark.Mode != ChannelWatermarkMode.Intermittent ||
maybeFadePoints.Map(fp => fp.Count > 0).IfNone(false)))
{
foreach (string path in options.ImagePath)
{
var watermarkInputFile = new WatermarkInputFile(
path,
new List<VideoStream>
{
new(
options.ImageStreamIndex.IfNone(0),
"unknown",
string.Empty,
new PixelFormatUnknown(),
ColorParams.Default,
new FrameSize(1, 1),
string.Empty,
string.Empty,
Option<string>.None,
!options.IsAnimated,
ScanKind.Progressive)
},
new WatermarkState(
maybeFadePoints.Map(lst => lst.Map(fp =>
{
return fp switch
{
FadeInPoint fip => (WatermarkFadePoint)new WatermarkFadeIn(
fip.Time,
fip.EnableStart,
fip.EnableFinish),
FadeOutPoint fop => new WatermarkFadeOut(
fop.Time,
fop.EnableStart,
fop.EnableFinish),
_ => throw new NotSupportedException() // this will never happen
};
}).ToList()),
watermark.Location,
watermark.Size,
watermark.WidthPercent,
watermark.HorizontalMarginPercent,
watermark.VerticalMarginPercent,
watermark.Opacity,
watermark.PlaceWithinSourceContent));
return watermarkInputFile;
}
}
}
}
return None;
}
private Command GetCommand(
string ffmpegPath,
Option<VideoInputFile> videoInputFile,

2
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -26,7 +26,7 @@ public interface IFFmpegProcessService @@ -26,7 +26,7 @@ public interface IFFmpegProcessService
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark,
List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark,
string vaapiDisplay,
VaapiDriver vaapiDriver,

11
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -426,10 +426,13 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -426,10 +426,13 @@ public class PlayoutBuilder : IPlayoutBuilder
// so only log the warning, and leave the bad data to fail tests
// playout.Items.Remove(futureItem);
_logger.LogInformation(
"{Count} playout items are scheduled after hard stop of {HardStop}; this is expected if duration is used.",
futureItemCount,
trimAfter);
if (futureItemCount > 0)
{
_logger.LogInformation(
"{Count} playout items are scheduled after hard stop of {HardStop}; this is expected if duration is used.",
futureItemCount,
trimAfter);
}
}
return playout;

7
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs

@ -157,13 +157,18 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche @@ -157,13 +157,18 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc);

7
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs

@ -76,13 +76,18 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul @@ -76,13 +76,18 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
var enumeratorStates = new Dictionary<CollectionKey, CollectionEnumeratorState>();
foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{

7
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs

@ -100,13 +100,18 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche @@ -100,13 +100,18 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
playoutItems.AddRange(

7
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs

@ -47,13 +47,18 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI @@ -47,13 +47,18 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
List<PlayoutItem> playoutItems = AddFiller(
playoutBuilderState,
collectionEnumerators,

12
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs

@ -58,17 +58,23 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou @@ -58,17 +58,23 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode
GuideGroup = context.PeekNextGuideGroup()
GuideGroup = context.PeekNextGuideGroup(),
//GuideStart = effectiveBlock.Start.UtcDateTime,
//GuideFinish = blockFinish.UtcDateTime,
//BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
//CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
//CollectionEtag = collectionEtags[collectionKey]
PlayoutItemWatermarks = []
};
foreach (int watermarkId in context.GetChannelWatermarkId())
foreach (int watermarkId in context.GetChannelWatermarkIds())
{
playoutItem.WatermarkId = watermarkId;
playoutItem.PlayoutItemWatermarks.Add(
new PlayoutItemWatermark
{
PlayoutItem = playoutItem,
WatermarkId = watermarkId
});
}
await AddItemAndMidRoll(context, playoutItem, mediaItem, executeSequence);

14
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs

@ -83,17 +83,23 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay @@ -83,17 +83,23 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode
GuideGroup = context.PeekNextGuideGroup()
GuideGroup = context.PeekNextGuideGroup(),
//GuideStart = effectiveBlock.Start.UtcDateTime,
//GuideFinish = blockFinish.UtcDateTime,
//BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
//CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
//CollectionEtag = collectionEtags[collectionKey]
//CollectionEtag = collectionEtags[collectionKey],
PlayoutItemWatermarks = []
};
foreach (int watermarkId in context.GetChannelWatermarkId())
foreach (int watermarkId in context.GetChannelWatermarkIds())
{
playoutItem.WatermarkId = watermarkId;
playoutItem.PlayoutItemWatermarks.Add(
new PlayoutItemWatermark
{
PlayoutItem = playoutItem,
WatermarkId = watermarkId
});
}
await AddItemAndMidRoll(context, playoutItem, mediaItem, executeSequence);

12
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs

@ -122,12 +122,18 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP @@ -122,12 +122,18 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
GuideGroup = context.PeekNextGuideGroup(),
FillerKind = fillerKind,
CustomTitle = string.IsNullOrWhiteSpace(customTitle) ? null : customTitle,
DisableWatermarks = disableWatermarks
DisableWatermarks = disableWatermarks,
PlayoutItemWatermarks = []
};
foreach (int watermarkId in context.GetChannelWatermarkId())
foreach (int watermarkId in context.GetChannelWatermarkIds())
{
playoutItem.WatermarkId = watermarkId;
playoutItem.PlayoutItemWatermarks.Add(
new PlayoutItemWatermark
{
PlayoutItem = playoutItem,
WatermarkId = watermarkId
});
}
if (remainingToFill - itemDuration >= TimeSpan.Zero || !stopBeforeEnd)

41
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs

@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; @@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) : IYamlPlayoutHandler
{
private readonly Dictionary<string, Option<ChannelWatermark>> _watermarkCache = new();
public bool Reset => false;
public async Task<bool> Handle(
@ -24,17 +26,48 @@ public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) : @@ -24,17 +26,48 @@ public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) :
if (watermark.Watermark && !string.IsNullOrWhiteSpace(watermark.Name))
{
Option<ChannelWatermark> maybeWatermark = await channelRepository.GetWatermarkByName(watermark.Name);
foreach (ChannelWatermark channelWatermark in maybeWatermark)
foreach (var wm in await GetChannelWatermarkByName(watermark.Name))
{
context.SetChannelWatermarkId(channelWatermark.Id);
context.SetChannelWatermarkId(wm.Id);
}
}
else
{
context.ClearChannelWatermarkId();
if (!string.IsNullOrWhiteSpace(watermark.Name))
{
foreach (var wm in await GetChannelWatermarkByName(watermark.Name))
{
context.RemoveChannelWatermarkId(wm.Id);
}
}
else
{
context.ClearChannelWatermarkIds();
}
}
return true;
}
private async Task<Option<ChannelWatermark>> GetChannelWatermarkByName(string name)
{
if (_watermarkCache.TryGetValue(name, out var cachedWatermark))
{
foreach (ChannelWatermark channelWatermark in cachedWatermark)
{
return channelWatermark;
}
}
else
{
Option<ChannelWatermark> maybeWatermark = await channelRepository.GetWatermarkByName(name);
_watermarkCache.Add(name, maybeWatermark);
foreach (ChannelWatermark channelWatermark in maybeWatermark)
{
return channelWatermark;
}
}
return Option<ChannelWatermark>.None;
}
}

35
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs

@ -14,10 +14,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -14,10 +14,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
private readonly System.Collections.Generic.HashSet<int> _visitedInstructions = [];
private readonly Stack<FillerKind> _fillerKind = new();
private readonly System.Collections.Generic.HashSet<int> _channelWatermarkIds = [];
private int _guideGroup = guideGroup;
private bool _guideGroupLocked;
private int _instructionIndex;
private Option<int> _channelWatermarkId;
private Option<string> _preRollSequence;
private Option<string> _postRollSequence;
private Option<MidRollSequence> _midRollSequence;
@ -84,15 +84,20 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -84,15 +84,20 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
public void SetChannelWatermarkId(int id)
{
_channelWatermarkId = id;
_channelWatermarkIds.Add(id);
}
public void ClearChannelWatermarkId()
public void RemoveChannelWatermarkId(int id)
{
_channelWatermarkId = Option<int>.None;
_channelWatermarkIds.Remove(id);
}
public Option<int> GetChannelWatermarkId() => _channelWatermarkId;
public void ClearChannelWatermarkIds()
{
_channelWatermarkIds.Clear();
}
public List<int> GetChannelWatermarkIds() => _channelWatermarkIds.ToList();
public void SetPreRollSequence(string sequence)
{
@ -142,19 +147,19 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -142,19 +147,19 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
public string Serialize()
{
int? channelWatermarkId = null;
foreach (int id in _channelWatermarkId)
{
channelWatermarkId = id;
}
string preRollSequence = null;
foreach (string sequence in _preRollSequence)
{
preRollSequence = sequence;
}
var state = new State(_instructionIndex, _guideGroup, _guideGroupLocked, channelWatermarkId, preRollSequence);
var state = new State(
_instructionIndex,
_guideGroup,
_guideGroupLocked,
_channelWatermarkIds.ToList(),
preRollSequence);
return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings);
}
@ -184,9 +189,9 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -184,9 +189,9 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
_guideGroupLocked = guideGroupLocked;
}
foreach (int channelWatermarkId in Optional(state.ChannelWatermarkId))
foreach (int channelWatermarkId in state.ChannelWatermarkIds)
{
_channelWatermarkId = channelWatermarkId;
_channelWatermarkIds.Add(channelWatermarkId);
}
foreach (string preRollSequence in Optional(state.PreRollSequence))
@ -199,7 +204,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -199,7 +204,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
int? InstructionIndex,
int? GuideGroup,
bool? GuideGroupLocked,
int? ChannelWatermarkId,
List<int> ChannelWatermarkIds,
string PreRollSequence);
public record MidRollSequence(string Sequence, string Expression);

6223
ErsatzTV.Infrastructure.MySql/Migrations/20250806191133_Add_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

51
ErsatzTV.Infrastructure.MySql/Migrations/20250806191133_Add_PlayoutItemWatermark.cs

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlayoutItemWatermark",
columns: table => new
{
PlayoutItemId = table.Column<int>(type: "int", nullable: false),
WatermarkId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayoutItemWatermark", x => new { x.PlayoutItemId, x.WatermarkId });
table.ForeignKey(
name: "FK_PlayoutItemWatermark_ChannelWatermark_WatermarkId",
column: x => x.WatermarkId,
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlayoutItemWatermark_PlayoutItem_PlayoutItemId",
column: x => x.PlayoutItemId,
principalTable: "PlayoutItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PlayoutItemWatermark_WatermarkId",
table: "PlayoutItemWatermark",
column: "WatermarkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlayoutItemWatermark");
}
}
}

6223
ErsatzTV.Infrastructure.MySql/Migrations/20250806191615_Populate_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

23
ErsatzTV.Infrastructure.MySql/Migrations/20250806191615_Populate_PlayoutItemWatermark.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Populate_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"INSERT INTO `PlayoutItemWatermark` (`PlayoutItemId`, `WatermarkId`)
SELECT `Id`, `WatermarkId` FROM `PlayoutItem` WHERE `WatermarkId` IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6211
ErsatzTV.Infrastructure.MySql/Migrations/20250806195038_Remove_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

49
ErsatzTV.Infrastructure.MySql/Migrations/20250806195038_Remove_PlayoutItemWatermark.cs

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Remove_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PlayoutItem_ChannelWatermark_WatermarkId",
table: "PlayoutItem");
migrationBuilder.DropIndex(
name: "IX_PlayoutItem_WatermarkId",
table: "PlayoutItem");
migrationBuilder.DropColumn(
name: "WatermarkId",
table: "PlayoutItem");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "WatermarkId",
table: "PlayoutItem",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PlayoutItem_WatermarkId",
table: "PlayoutItem",
column: "WatermarkId");
migrationBuilder.AddForeignKey(
name: "FK_PlayoutItem_ChannelWatermark_WatermarkId",
table: "PlayoutItem",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}

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

@ -1882,18 +1882,28 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1882,18 +1882,28 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("SubtitleMode")
.HasColumnType("int");
b.Property<int?>("WatermarkId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId");
b.ToTable("PlayoutItem", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.Property<int>("PlayoutItemId")
.HasColumnType("int");
b.Property<int>("WatermarkId")
.HasColumnType("int");
b.HasKey("PlayoutItemId", "WatermarkId");
b.HasIndex("WatermarkId");
b.ToTable("PlayoutItem", (string)null);
b.ToTable("PlayoutItemWatermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
@ -4617,14 +4627,26 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4617,14 +4627,26 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("MediaItem");
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
.WithMany("PlayoutItemWatermarks")
.HasForeignKey("PlayoutItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany("PlayoutItemWatermarks")
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayoutItem");
b.Navigation("Watermark");
});
@ -5748,6 +5770,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5748,6 +5770,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Navigation("CollectionItems");
@ -5930,6 +5957,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5930,6 +5957,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Templates");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");

6060
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191226_Add_PlayoutItemWatermarkTable.Designer.cs generated

File diff suppressed because it is too large Load Diff

50
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191226_Add_PlayoutItemWatermarkTable.cs

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutItemWatermarkTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlayoutItemWatermark",
columns: table => new
{
PlayoutItemId = table.Column<int>(type: "INTEGER", nullable: false),
WatermarkId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayoutItemWatermark", x => new { x.PlayoutItemId, x.WatermarkId });
table.ForeignKey(
name: "FK_PlayoutItemWatermark_ChannelWatermark_WatermarkId",
column: x => x.WatermarkId,
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlayoutItemWatermark_PlayoutItem_PlayoutItemId",
column: x => x.PlayoutItemId,
principalTable: "PlayoutItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PlayoutItemWatermark_WatermarkId",
table: "PlayoutItemWatermark",
column: "WatermarkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlayoutItemWatermark");
}
}
}

6060
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191314_Populate_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

23
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191314_Populate_PlayoutItemWatermark.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Populate_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"INSERT INTO `PlayoutItemWatermark` (`PlayoutItemId`, `WatermarkId`)
SELECT `Id`, `WatermarkId` FROM `PlayoutItem` WHERE `WatermarkId` IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6048
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806195120_Remove_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

49
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806195120_Remove_PlayoutItemWatermark.cs

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Remove_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PlayoutItem_ChannelWatermark_WatermarkId",
table: "PlayoutItem");
migrationBuilder.DropIndex(
name: "IX_PlayoutItem_WatermarkId",
table: "PlayoutItem");
migrationBuilder.DropColumn(
name: "WatermarkId",
table: "PlayoutItem");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "WatermarkId",
table: "PlayoutItem",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PlayoutItem_WatermarkId",
table: "PlayoutItem",
column: "WatermarkId");
migrationBuilder.AddForeignKey(
name: "FK_PlayoutItem_ChannelWatermark_WatermarkId",
table: "PlayoutItem",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}

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

@ -1793,18 +1793,28 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1793,18 +1793,28 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("SubtitleMode")
.HasColumnType("INTEGER");
b.Property<int?>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId");
b.ToTable("PlayoutItem", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.Property<int>("PlayoutItemId")
.HasColumnType("INTEGER");
b.Property<int>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutItemId", "WatermarkId");
b.HasIndex("WatermarkId");
b.ToTable("PlayoutItem", (string)null);
b.ToTable("PlayoutItemWatermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
@ -4454,14 +4464,26 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4454,14 +4464,26 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("MediaItem");
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
.WithMany("PlayoutItemWatermarks")
.HasForeignKey("PlayoutItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany("PlayoutItemWatermarks")
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayoutItem");
b.Navigation("Watermark");
});
@ -5585,6 +5607,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5585,6 +5607,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Navigation("CollectionItems");
@ -5767,6 +5794,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5767,6 +5794,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Templates");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");

17
ErsatzTV.Infrastructure/Data/Configurations/PlayoutItemConfiguration.cs

@ -16,10 +16,17 @@ public class PlayoutItemConfiguration : IEntityTypeConfiguration<PlayoutItem> @@ -16,10 +16,17 @@ public class PlayoutItemConfiguration : IEntityTypeConfiguration<PlayoutItem>
.HasForeignKey(pi => pi.MediaItemId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Watermark)
.WithMany()
.HasForeignKey(i => i.WatermarkId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasMany(c => c.Watermarks)
.WithMany(m => m.PlayoutItems)
.UsingEntity<PlayoutItemWatermark>(
j => j.HasOne(ci => ci.Watermark)
.WithMany(mi => mi.PlayoutItemWatermarks)
.HasForeignKey(ci => ci.WatermarkId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(ci => ci.PlayoutItem)
.WithMany(c => c.PlayoutItemWatermarks)
.HasForeignKey(ci => ci.PlayoutItemId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(ci => new { ci.PlayoutItemId, ci.WatermarkId }));
}
}

2
ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs

@ -89,6 +89,8 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine @@ -89,6 +89,8 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
}
finally
{
await pipeWriter.CompleteAsync();
foreach (var element in elements.OfType<IDisposable>())
{
element.Dispose();

17
ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs

@ -43,7 +43,22 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -43,7 +43,22 @@ public class WatermarkElement : IGraphicsElement, IDisposable
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{
if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
if (_watermark.Mode is ChannelWatermarkMode.Intermittent)
{
string expressionString = $@"
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < 1,
(time_of_day_seconds % {_watermark.FrequencyMinutes * 60}),
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds},
1,
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds + 1},
1 - ((time_of_day_seconds % {_watermark.FrequencyMinutes * 60} - {1 + _watermark.DurationSeconds}) / 1),
0
)
)
)";
_expression = new Expression(expressionString);
}
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
{
_expression = new Expression(_watermark.OpacityExpression);
}

4
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -367,7 +367,7 @@ public class TranscodingTests @@ -367,7 +367,7 @@ public class TranscodingTests
now,
now + TimeSpan.FromSeconds(3),
now,
Option<ChannelWatermark>.None,
[],
GetWatermark(watermark),
"drm",
VaapiDriver.RadeonSI,
@ -646,7 +646,7 @@ public class TranscodingTests @@ -646,7 +646,7 @@ public class TranscodingTests
now,
now + TimeSpan.FromSeconds(3),
now,
Option<ChannelWatermark>.None,
[],
channelWatermark,
"drm",
VaapiDriver.RadeonSI,

18
ErsatzTV/Pages/YamlValidator.razor

@ -97,12 +97,20 @@ @@ -97,12 +97,20 @@
if (_exists)
{
_yamlText = await File.ReadAllTextAsync(_yamlFile);
_jsonText = YamlScheduleValidator.ToJson(_yamlText);
var messages = await YamlScheduleValidator.GetValidationMessages(_yamlText, _isImport);
_messagesCount = messages.Count;
_messages = string.Join("\n", messages);
try
{
_jsonText = YamlScheduleValidator.ToJson(_yamlText);
var messages = await YamlScheduleValidator.GetValidationMessages(_yamlText, _isImport);
_messagesCount = messages.Count;
_messages = string.Join("\n", messages);
StateHasChanged();
StateHasChanged();
}
catch (Exception e)
{
_messages = e.ToString();
_messagesCount = 1;
}
}
}

15
ErsatzTV/Resources/yaml-playout-import.schema.json

@ -180,7 +180,8 @@ @@ -180,7 +180,8 @@
"all": { "type": "null" },
"content": { "type": "string" },
"custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "all", "content" ],
"additionalProperties": false
@ -191,7 +192,8 @@ @@ -191,7 +192,8 @@
"count": { "type": "integer" },
"content": { "type": "string" },
"custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "count", "content" ],
"additionalProperties": false
@ -207,7 +209,8 @@ @@ -207,7 +209,8 @@
"offline_tail": { "type": "boolean" },
"stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "duration", "content" ],
"additionalProperties": false
@ -221,7 +224,8 @@ @@ -221,7 +224,8 @@
"fallback": { "type": "string" },
"discard_attempts": { "type": "integer" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "pad_to_next", "content" ],
"additionalProperties": false
@ -238,7 +242,8 @@ @@ -238,7 +242,8 @@
"discard_attempts": { "type": "integer" },
"stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "pad_until", "content" ],
"additionalProperties": false

15
ErsatzTV/Resources/yaml-playout.schema.json

@ -229,7 +229,8 @@ @@ -229,7 +229,8 @@
"all": { "type": "null" },
"content": { "type": "string" },
"custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "all", "content" ],
"additionalProperties": false
@ -240,7 +241,8 @@ @@ -240,7 +241,8 @@
"count": { "type": "integer" },
"content": { "type": "string" },
"custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "count", "content" ],
"additionalProperties": false
@ -256,7 +258,8 @@ @@ -256,7 +258,8 @@
"offline_tail": { "type": "boolean" },
"stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "duration", "content" ],
"additionalProperties": false
@ -270,7 +273,8 @@ @@ -270,7 +273,8 @@
"fallback": { "type": "string" },
"discard_attempts": { "type": "integer" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "pad_to_next", "content" ],
"additionalProperties": false
@ -287,7 +291,8 @@ @@ -287,7 +291,8 @@
"discard_attempts": { "type": "integer" },
"stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "pad_until", "content" ],
"additionalProperties": false

Loading…
Cancel
Save