add channel slugs (#2823)

* add channel slugs

* safety
This commit is contained in:
Jason Dove
2026-02-14 19:57:35 -06:00
committed by GitHub
parent 3dbde17f68
commit c6d538e012
25 changed files with 14202 additions and 12 deletions

View File

@@ -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**

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
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
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
// 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
_logger.LogDebug("HLS session state: {State}", _state);
DateTimeOffset now = wasSeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime
or HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
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);

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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
_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 Normal file

Binary file not shown.

View File

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

View File

@@ -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
Group,
Categories,
FFmpegProfileId,
SlugSeconds,
string.IsNullOrWhiteSpace(ExternalLogoUrl)
? Logo
: new ArtworkContentTypeModel(ExternalLogoUrl, string.Empty),
@@ -90,6 +92,7 @@ public class ChannelEditViewModel
Group,
Categories,
FFmpegProfileId,
SlugSeconds,
string.IsNullOrWhiteSpace(ExternalLogoUrl)
? Logo
: new ArtworkContentTypeModel(ExternalLogoUrl, string.Empty),