Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `mpeg2video` encoder support (software, some Intel QSV and Intel VAAPI devices)
- Add song playback support, using legacy-generated background image and text
- Song Video Mode `With Progress` is not supported yet; these songs play without a progress bar
- Support FFmpeg Profile `Normalize Frame Rate`
- As with the legacy engine, the target is the lowest frame rate above 23 fps among the channel's scheduled items
- Frames are dropped or duplicated to reach the target, instead of blended
- With video copy, only items already at the target rate are copied; others are transcoded
- Add `Deep Scan Library` button to local libraries
- A deep scan re-probes every file and refreshes all metadata and artwork, even when files appear unchanged
- This forces a refresh of e.g. NFO files or posters that were edited without changing their modified time
Expand Down
51 changes: 50 additions & 1 deletion ErsatzTV.Application/Streaming/ChannelConfigConverter.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.IO.Abstractions;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.FFmpegProfiles;
Expand All @@ -6,18 +7,20 @@
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Next.Config;
using ErsatzTV.FFmpeg;
using Subtitle = ErsatzTV.Core.Next.Config.Subtitle;

namespace ErsatzTV.Application.Streaming;

public class ChannelConfigConverter(IConfigElementRepository configElementRepository, IFileSystem fileSystem)
: IChannelConfigConverter
{
public static readonly string ChannelConfigVersion = "https://ersatztv.org/channel/version/0.1.0";
public static readonly string ChannelConfigVersion = "https://ersatztv.org/channel/version/0.1.1";

public async Task<ChannelConfig> ToNext(
ChannelViewModel channel,
FFmpegProfileViewModel ffmpegProfile,
Option<FrameRate> targetFramerate,
CancellationToken cancellationToken)
{
var ffmpeg = new Ffmpeg
Expand Down Expand Up @@ -147,6 +150,11 @@ public async Task<ChannelConfig> ToNext(
}
};

foreach (string frameRate in targetFramerate.Bind(ToNextFrameRate))
{
videoNormalization.FrameRate = frameRate;
}

var subtitleNormalization = new Subtitle
{
Mode = channel.NextEngineTextSubtitleMode switch
Expand Down Expand Up @@ -179,4 +187,45 @@ public async Task<ChannelConfig> ToNext(
}
};
}

private const int MaxNextFrameRate = 240;

// reject decimals
public static Option<string> ToNextFrameRate(FrameRate frameRate)
{
string value = frameRate.RFrameRate.Trim();

string[] parts = value.Split('/');
if (parts.Length <= 2 && parts.All(p => p.Length > 0 && p.All(char.IsAsciiDigit)))
{
if (!long.TryParse(parts[0], out long num) ||
!long.TryParse(parts.Length == 2 ? parts[1] : "1", out long den) ||
num == 0 || den == 0 || num < den || num > den * MaxNextFrameRate)
{
return Option<string>.None;
}

return value;
}

if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double rate) ||
rate < 1 || rate > MaxNextFrameRate)
{
return Option<string>.None;
}

double integer = Math.Round(rate);
if (Math.Abs(rate - integer) < 0.001)
{
return ((long)integer).ToString(CultureInfo.InvariantCulture);
}

double ntsc = Math.Round(rate * 1.001);
if (Math.Abs(rate * 1.001 - ntsc) < 0.005)
{
return $"{(long)ntsc * 1000}/1001";
}

return $"{(long)Math.Round(rate * 1000)}/1000";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Next.Config;
using ErsatzTV.FFmpeg;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -68,9 +69,9 @@ private async Task<Either<BaseError, IHlsSessionWorker>> CreateWorker(

Option<TimeSpan> idleTimeout = Option<TimeSpan>.None;

// Option<FrameRate> targetFramerate = await mediator.Send(
// new GetChannelFramerate(request.ChannelNumber),
// cancellationToken);
Option<FrameRate> targetFramerate = await mediator.Send(
new GetChannelFramerate(request.ChannelNumber),
cancellationToken);

Option<ChannelViewModel> maybeChannel =
await mediator.Send(new GetChannelByNumber(request.ChannelNumber), cancellationToken);
Expand Down Expand Up @@ -105,7 +106,11 @@ private async Task<Either<BaseError, IHlsSessionWorker>> CreateWorker(

PrepareTranscodeFolder(request.ChannelNumber);

ChannelConfig config = await channelConfigConverter.ToNext(channel, ffmpegProfile, cancellationToken);
ChannelConfig config = await channelConfigConverter.ToNext(
channel,
ffmpegProfile,
targetFramerate,
cancellationToken);

NextSessionWorker worker = new NextSessionWorker(
channelBinary,
Expand Down
2 changes: 2 additions & 0 deletions ErsatzTV.Application/Streaming/IChannelConfigConverter.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.FFmpeg;

namespace ErsatzTV.Application.Streaming;

Expand All @@ -8,5 +9,6 @@ public interface IChannelConfigConverter
Task<Core.Next.Config.ChannelConfig> ToNext(
ChannelViewModel channel,
FFmpegProfileViewModel ffmpegProfile,
Option<FrameRate> targetFramerate,
CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ private async Task<Either<BaseError, PlayoutItemResult>> GetNextProcess(
ChannelConfig config = await channelConfigConverter.ToNext(
Channels.Mapper.ProjectToViewModel(channel, playoutCount: 0),
FFmpegProfiles.Mapper.ProjectToViewModel(ffmpegProfile),
Option<FrameRate>.None,
cancellationToken);

config.Playout.VirtualStart = start.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK", CultureInfo.InvariantCulture);
Expand Down
8 changes: 8 additions & 0 deletions ErsatzTV.Core/Next/Config/ChannelConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ public partial class Video
[JsonPropertyName("format")]
public VideoFormat? Format { get; set; }

/// <summary>
/// Output frame rate, `N` or `N/D` (e.g. `25`, `30000/1001`).
/// Frames are dropped or repeated to match it.
/// In `copy` mode, only items at this rate are copied. Unset keeps the source rate.
/// </summary>
[JsonPropertyName("frame_rate")]
public string? FrameRate { get; set; }

[JsonPropertyName("height")]
public long? Height { get; set; }

Expand Down
47 changes: 45 additions & 2 deletions ErsatzTV.Tests/Streaming/ChannelConfigConverterTests.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
using System.IO.Abstractions;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Next.Config;
using ErsatzTV.FFmpeg;
using LanguageExt;
using NUnit.Framework;
using Testably.Abstractions;
Expand Down Expand Up @@ -131,9 +131,52 @@ public async Task Transcode_profile_should_transcode()
config.Normalization.Video.Mode.ShouldBe(StreamMode.Transcode);
}

[Test]
public async Task No_target_framerate_should_pass_source_rate_through()
{
ChannelConfig config = await Convert(Profile);

config.Normalization.Video.FrameRate.ShouldBeNull();
}

[Test]
public async Task Target_framerate_should_set_frame_rate()
{
ChannelConfig config = await Convert(Profile, new FrameRate("24000/1001"));

config.Normalization.Video.FrameRate.ShouldBe("24000/1001");
}

[TestCase("24000/1001", "24000/1001")]
[TestCase("25/1", "25/1")]
[TestCase("30", "30")]
[TestCase("23.976", "24000/1001")]
[TestCase("23.98", "24000/1001")]
[TestCase("29.97", "30000/1001")]
[TestCase("59.94", "60000/1001")]
[TestCase("25.00", "25")]
[TestCase("12.5", "12500/1000")]
public void ToNextFrameRate_should_convert_to_rational(string input, string expected) =>
ChannelConfigConverter.ToNextFrameRate(new FrameRate(input)).ShouldBe(Option<string>.Some(expected));

[TestCase("")]
[TestCase("0/0")]
[TestCase("30/0")]
[TestCase("1/2")]
[TestCase("0.5")]
[TestCase("241")]
[TestCase("1000/1")]
[TestCase("abc")]
[TestCase("-30")]
public void ToNextFrameRate_should_reject_unusable_rates(string input) =>
ChannelConfigConverter.ToNextFrameRate(new FrameRate(input)).IsNone.ShouldBeTrue();

private static Task<ChannelConfig> Convert(FFmpegProfileViewModel profile) =>
Convert(profile, Option<FrameRate>.None);

private static Task<ChannelConfig> Convert(FFmpegProfileViewModel profile, Option<FrameRate> targetFramerate) =>
new ChannelConfigConverter(new EmptyConfigElementRepository(), new RealFileSystem())
.ToNext(Channel, profile, CancellationToken.None);
.ToNext(Channel, profile, targetFramerate, CancellationToken.None);

private sealed class EmptyConfigElementRepository : IConfigElementRepository
{
Expand Down
Loading