Browse Source

add channel slugs (#2823)

* add channel slugs

* safety
pull/2825/head
Jason Dove 2 months ago committed by GitHub
parent
commit
c6d538e012
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  3. 1
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  4. 1
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  5. 1
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  6. 1
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  7. 1
      ErsatzTV.Application/Channels/Mapper.cs
  8. 3
      ErsatzTV.Application/Channels/Queries/GetSlugSecondsByChannelNumber.cs
  9. 17
      ErsatzTV.Application/Channels/Queries/GetSlugSecondsByChannelNumberHandler.cs
  10. 2
      ErsatzTV.Application/Streaming/HlsSessionState.cs
  11. 52
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  12. 22
      ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumber.cs
  13. 111
      ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs
  14. 1
      ErsatzTV.Core/Domain/Channel.cs
  15. 7045
      ErsatzTV.Infrastructure.MySql/Migrations/20260215014015_Add_ChannelSlugSeconds.Designer.cs
  16. 28
      ErsatzTV.Infrastructure.MySql/Migrations/20260215014015_Add_ChannelSlugSeconds.cs
  17. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  18. 6872
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260215013932_Add_ChannelSlugSeconds.Designer.cs
  19. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260215013932_Add_ChannelSlugSeconds.cs
  20. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  21. 1
      ErsatzTV/ErsatzTV.csproj
  22. 14
      ErsatzTV/Pages/ChannelEditor.razor
  23. BIN
      ErsatzTV/Resources/slug.mp4
  24. 1
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  25. 3
      ErsatzTV/ViewModels/ChannelEditViewModel.cs

4
CHANGELOG.md

@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
- Block schedule info includes block, block item, playback order, random seed, collection index
- E.g. items with the same random seed are part of the same shuffle
- Add channel setting `Slug Seconds`
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
- If this feature turns out to be popular, methods to correct the drift may be investigated
### Changed
- Move dark/light mode toggle to **Settings** > **UI**

1
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -11,6 +11,7 @@ public record ChannelViewModel( @@ -11,6 +11,7 @@ public record ChannelViewModel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,

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

@ -10,6 +10,7 @@ public record CreateChannel( @@ -10,6 +10,7 @@ public record CreateChannel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,

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

@ -80,6 +80,7 @@ public class CreateChannelHandler( @@ -80,6 +80,7 @@ public class CreateChannelHandler(
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = request.SlugSeconds,
PlayoutSource = request.PlayoutSource,
PlayoutMode = request.PlayoutMode,
MirrorSourceChannelId = request.MirrorSourceChannelId,

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

@ -11,6 +11,7 @@ public record UpdateChannel( @@ -11,6 +11,7 @@ public record UpdateChannel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,

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

@ -52,6 +52,7 @@ public class UpdateChannelHandler( @@ -52,6 +52,7 @@ public class UpdateChannelHandler(
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.SlugSeconds = update.SlugSeconds;
c.StreamSelectorMode = update.StreamSelectorMode;
c.StreamSelector = update.StreamSelector;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;

1
ErsatzTV.Application/Channels/Mapper.cs

@ -14,6 +14,7 @@ internal static class Mapper @@ -14,6 +14,7 @@ internal static class Mapper
channel.Group,
channel.Categories,
channel.FFmpegProfileId,
channel.SlugSeconds,
GetLogo(channel),
channel.StreamSelectorMode,
channel.StreamSelector,

3
ErsatzTV.Application/Channels/Queries/GetSlugSecondsByChannelNumber.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;

17
ErsatzTV.Application/Channels/Queries/GetSlugSecondsByChannelNumberHandler.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetSlugSecondsByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSlugSecondsByChannelNumber, Option<double>>
{
public async Task<Option<double>> Handle(GetSlugSecondsByChannelNumber request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Channels
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken)
.Map(c => Optional(c?.SlugSeconds));
}
}

2
ErsatzTV.Application/Streaming/HlsSessionState.cs

@ -6,5 +6,7 @@ public enum HlsSessionState @@ -6,5 +6,7 @@ public enum HlsSessionState
ZeroAndWorkAhead,
SeekAndRealtime,
ZeroAndRealtime,
SlugAndWorkAhead,
SlugAndRealtime,
PlayoutUpdated
}

52
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -8,6 +8,7 @@ using System.Timers; @@ -8,6 +8,7 @@ using System.Timers;
using Bugsnag;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@ -54,6 +55,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -54,6 +55,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private Timer _timer;
private DateTimeOffset _transcodedUntil;
private string _workingDirectory;
private Option<double> _slugSeconds;
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
@ -215,6 +217,10 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -215,6 +217,10 @@ public class HlsSessionWorker : IHlsSessionWorker
new GetPlayoutIdByChannelNumber(_channelNumber),
cancellationToken);
_slugSeconds = await _mediator.Send(
new GetSlugSecondsByChannelNumber(_channelNumber),
cancellationToken);
// time shift on-demand playout if needed
foreach (int playoutId in maybePlayoutId)
{
@ -382,6 +388,13 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -382,6 +388,13 @@ public class HlsSessionWorker : IHlsSessionWorker
// after seeking and NOT completing the item, seek again, transcode method will accelerate if needed
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
// switch back to normal item after slug
HlsSessionState.SlugAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
HlsSessionState.SlugAndRealtime => HlsSessionState.ZeroAndRealtime,
// after completing the item, insert a slug
_ when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndWorkAhead,
// after seeking and completing the item, start at zero
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
@ -456,19 +469,32 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -456,19 +469,32 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("HLS session state: {State}", _state);
DateTimeOffset now = wasSeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime;
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime
or HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
bool isSlug = _state is HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
FFmpegProcessRequest request = isSlug
? new GetSlugProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
_slugSeconds)
: new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
// _logger.LogInformation("Request {@Request}", request);

22
ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumber.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.Streaming;
public record GetSlugProcessByChannelNumber(
string ChannelNumber,
StreamingMode Mode,
DateTimeOffset Now,
bool HlsRealtime,
DateTimeOffset ChannelStart,
TimeSpan PtsOffset,
Option<FrameRate> TargetFramerate,
Option<double> SlugSeconds) : FFmpegProcessRequest(
ChannelNumber,
Mode,
Now,
StartAtZero: true,
HlsRealtime,
ChannelStart,
PtsOffset,
FFmpegProfileId: Option<int>.None);

111
ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs

@ -0,0 +1,111 @@ @@ -0,0 +1,111 @@
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming;
public class GetSlugProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
IFFmpegProcessService ffmpegProcessService,
ILocalStatisticsProvider localStatisticsProvider)
: FFmpegProcessHandler<GetSlugProcessByChannelNumber>(dbContextFactory)
{
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetSlugProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
string videoPath = fileSystem.Path.Combine(FileSystemLayout.ResourcesCacheFolder, "slug.mp4");
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken)
.Map(result => result.IfNone(false));
Either<BaseError, MediaVersion> maybeVersion =
await localStatisticsProvider.GetStatistics(ffprobePath, videoPath);
foreach (var error in maybeVersion.LeftToSeq())
{
return error;
}
var version = maybeVersion.RightToSeq().Head();
var mediaItem = new OtherVideo
{
MediaVersions = [version]
};
TimeSpan duration = version.Duration;
foreach (double slugSeconds in request.SlugSeconds)
{
TimeSpan seconds = TimeSpan.FromSeconds(slugSeconds);
if (seconds > TimeSpan.Zero && seconds < duration)
{
duration = seconds;
}
}
DateTimeOffset finish = request.Now.Add(duration);
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
saveReports,
channel,
new MediaItemVideoVersion(mediaItem, version),
new MediaItemAudioVersion(mediaItem, version),
videoPath,
videoPath,
_ => Task.FromResult<List<Subtitle>>([]),
string.Empty,
string.Empty,
string.Empty,
ChannelSubtitleMode.None,
request.Now,
finish,
request.Now,
duration,
[],
[],
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
request.HlsRealtime,
StreamInputKind.Vod,
FillerKind.None,
inPoint: TimeSpan.Zero,
request.ChannelStartTime,
request.PtsOffset,
request.TargetFramerate,
Option<string>.None,
_ => { },
canProxy: true,
cancellationToken);
var result = new PlayoutItemProcessModel(
playoutItemResult.Process,
playoutItemResult.GraphicsEngineContext,
duration,
finish,
isComplete: true,
request.Now.ToUnixTimeSeconds(),
playoutItemResult.MediaItemId,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
return Right<BaseError, PlayoutItemProcessModel>(result);
}
}

1
ErsatzTV.Core/Domain/Channel.cs

@ -17,6 +17,7 @@ public class Channel @@ -17,6 +17,7 @@ public class Channel
public string Categories { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
public double? SlugSeconds { get; set; }
public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? FallbackFillerId { get; set; }

7045
ErsatzTV.Infrastructure.MySql/Migrations/20260215014015_Add_ChannelSlugSeconds.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.MySql/Migrations/20260215014015_Add_ChannelSlugSeconds.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelSlugSeconds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "SlugSeconds",
table: "Channel",
type: "double",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SlugSeconds",
table: "Channel");
}
}
}

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

@ -357,6 +357,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -357,6 +357,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<double?>("SlugSeconds")
.HasColumnType("double");
b.Property<int>("SongVideoMode")
.HasColumnType("int");

6872
ErsatzTV.Infrastructure.Sqlite/Migrations/20260215013932_Add_ChannelSlugSeconds.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20260215013932_Add_ChannelSlugSeconds.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelSlugSeconds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "SlugSeconds",
table: "Channel",
type: "REAL",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SlugSeconds",
table: "Channel");
}
}
}

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

@ -344,6 +344,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -344,6 +344,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<double?>("SlugSeconds")
.HasColumnType("REAL");
b.Property<int>("SongVideoMode")
.HasColumnType("INTEGER");

1
ErsatzTV/ErsatzTV.csproj

@ -119,6 +119,7 @@ @@ -119,6 +119,7 @@
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Common.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Include="Resources\slug.mp4" />
</ItemGroup>
<ItemGroup>

14
ErsatzTV/Pages/ChannelEditor.razor

@ -154,6 +154,19 @@ else @@ -154,6 +154,19 @@ else
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Slug Seconds</MudText>
</div>
<MudSelect T="double?" @bind-Value="_model.SlugSeconds" For="@(() => _model.SlugSeconds)" HelperText="Seconds of black video and silent audio to insert between *every* playout item">
<MudSelectItem Value="@((double?)null)">(none)</MudSelectItem>
<MudSelectItem Value="@((double?)0.5)">0.5 seconds</MudSelectItem>
<MudSelectItem Value="@((double?)1)">1 second</MudSelectItem>
<MudSelectItem Value="@((double?)2)">2 seconds</MudSelectItem>
<MudSelectItem Value="@((double?)3)">3 seconds</MudSelectItem>
<MudSelectItem Value="@((double?)5)">5 seconds</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Stream Selector Mode</MudText>
@ -376,6 +389,7 @@ else @@ -376,6 +389,7 @@ else
_model.Categories = channelViewModel.Categories;
_model.Number = channelViewModel.Number;
_model.FFmpegProfileId = channelViewModel.FFmpegProfileId;
_model.SlugSeconds = channelViewModel.SlugSeconds;
if (channelViewModel.Logo.IsExternalUrl)
{

BIN
ErsatzTV/Resources/slug.mp4

Binary file not shown.

1
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -27,6 +27,7 @@ public class ResourceExtractorService : BackgroundService @@ -27,6 +27,7 @@ public class ResourceExtractorService : BackgroundService
await ExtractResource(assembly, "sequential-schedule.schema.json", stoppingToken);
await ExtractResource(assembly, "sequential-schedule-import.schema.json", stoppingToken);
await ExtractResource(assembly, "test.avs", stoppingToken);
await ExtractResource(assembly, "slug.mp4", stoppingToken);
await ExtractFontResource(assembly, "Sen.ttf", stoppingToken);
await ExtractFontResource(assembly, "Roboto-Regular.ttf", stoppingToken);

3
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -13,6 +13,7 @@ public class ChannelEditViewModel @@ -13,6 +13,7 @@ public class ChannelEditViewModel
public string Categories { get; set; }
public string Number { get; set; }
public int FFmpegProfileId { get; set; }
public double? SlugSeconds { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; }
@ -59,6 +60,7 @@ public class ChannelEditViewModel @@ -59,6 +60,7 @@ public class ChannelEditViewModel
Group,
Categories,
FFmpegProfileId,
SlugSeconds,
string.IsNullOrWhiteSpace(ExternalLogoUrl)
? Logo
: new ArtworkContentTypeModel(ExternalLogoUrl, string.Empty),
@ -90,6 +92,7 @@ public class ChannelEditViewModel @@ -90,6 +92,7 @@ public class ChannelEditViewModel
Group,
Categories,
FFmpegProfileId,
SlugSeconds,
string.IsNullOrWhiteSpace(ExternalLogoUrl)
? Logo
: new ArtworkContentTypeModel(ExternalLogoUrl, string.Empty),

Loading…
Cancel
Save