@@ -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**
|
||||
|
||||
@@ -11,6 +11,7 @@ public record ChannelViewModel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -10,6 +10,7 @@ public record CreateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,7 @@ public record UpdateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,7 @@ internal static class Mapper
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfileId,
|
||||
channel.SlugSeconds,
|
||||
GetLogo(channel),
|
||||
channel.StreamSelectorMode,
|
||||
channel.StreamSelector,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,7 @@ public enum HlsSessionState
|
||||
ZeroAndWorkAhead,
|
||||
SeekAndRealtime,
|
||||
ZeroAndRealtime,
|
||||
SlugAndWorkAhead,
|
||||
SlugAndRealtime,
|
||||
PlayoutUpdated
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Normal file
7045
ErsatzTV.Infrastructure.MySql/Migrations/20260215014015_Add_ChannelSlugSeconds.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Normal file
6872
ErsatzTV.Infrastructure.Sqlite/Migrations/20260215013932_Add_ChannelSlugSeconds.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Common.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Resources\slug.mp4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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
BIN
ErsatzTV/Resources/slug.mp4
Normal file
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user