diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a151f98..8123fe8cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example: + - en-US can match sunday using `day_of_week = 0` + - fr-FR can match sunday using `day_of_week = 6` + - As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time + - `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)` + ### Fixed - Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`) - Respect `z_index` (draw order) on all graphics element types diff --git a/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs index dc58bc84e..4632a2f75 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using Serilog; using Shouldly; using Testably.Abstractions.Testing; +using TimeZoneConverter; namespace ErsatzTV.Core.Tests.FFmpeg; @@ -569,6 +570,289 @@ public class CustomStreamSelectorTests } } + [Test] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail() + { + const string YAML = + """ + --- + items: + - audio_language: ["ja"] + subtitle_language: ["eng"] + content_condition: "day_of_week = 1" + + - audio_language: ["eng"] + disable_subtitles: true + """; + + var fileSystem = new MockFileSystem(); + fileSystem.Initialize() + .WithFile(TestFileName).Which(f => f.HasStringContent(YAML)); + var streamSelector = new CustomStreamSelector(fileSystem, _logger); + + var tz = TZConvert.GetTimeZoneInfo("America/Chicago"); + var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(1); + audioStream.Language.ShouldBe("eng"); + } + + result.Subtitle.IsSome.ShouldBeFalse(); + } + + [Test] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match() + { + const string YAML = + """ + --- + items: + - audio_language: ["ja"] + subtitle_language: ["eng"] + content_condition: "day_of_week = 0" + + - audio_language: ["eng"] + disable_subtitles: true + """; + + var fileSystem = new MockFileSystem(); + fileSystem.Initialize() + .WithFile(TestFileName).Which(f => f.HasStringContent(YAML)); + var streamSelector = new CustomStreamSelector(fileSystem, _logger); + + var tz = TZConvert.GetTimeZoneInfo("America/Chicago"); + var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(0); + audioStream.Language.ShouldBe("ja"); + } + + result.Subtitle.IsSome.ShouldBeTrue(); + + foreach (Subtitle subtitle in result.Subtitle) + { + subtitle.Id.ShouldBe(2); + subtitle.Language.ShouldBe("eng"); + } + } + + [Test] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before() + { + // saturday from 9pm-11pm + const string YAML = + """ + --- + items: + - audio_language: ["ja"] + subtitle_language: ["eng"] + content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)" + + - audio_language: ["eng"] + disable_subtitles: true + """; + + var fileSystem = new MockFileSystem(); + fileSystem.Initialize() + .WithFile(TestFileName).Which(f => f.HasStringContent(YAML)); + var streamSelector = new CustomStreamSelector(fileSystem, _logger); + + var tz = TZConvert.GetTimeZoneInfo("America/Chicago"); + var start = new DateTime(2026, 1, 10, 20, 59, 59, DateTimeKind.Unspecified); // saturday at 8:59:59pm + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(1); + audioStream.Language.ShouldBe("eng"); + } + + result.Subtitle.IsSome.ShouldBeFalse(); + } + + [Test] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After() + { + // saturday from 9pm-11pm + const string YAML = + """ + --- + items: + - audio_language: ["ja"] + subtitle_language: ["eng"] + content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)" + + - audio_language: ["eng"] + disable_subtitles: true + """; + + var fileSystem = new MockFileSystem(); + fileSystem.Initialize() + .WithFile(TestFileName).Which(f => f.HasStringContent(YAML)); + var streamSelector = new CustomStreamSelector(fileSystem, _logger); + + var tz = TZConvert.GetTimeZoneInfo("America/Chicago"); + var start = new DateTime(2026, 1, 10, 23, 0, 0, DateTimeKind.Unspecified); // saturday at 11:00pm + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(1); + audioStream.Language.ShouldBe("eng"); + } + + result.Subtitle.IsSome.ShouldBeFalse(); + } + + [Test] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day() + { + // saturday from 9pm-11pm + const string YAML = + """ + --- + items: + - audio_language: ["ja"] + subtitle_language: ["eng"] + content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)" + + - audio_language: ["eng"] + disable_subtitles: true + """; + + var fileSystem = new MockFileSystem(); + fileSystem.Initialize() + .WithFile(TestFileName).Which(f => f.HasStringContent(YAML)); + var streamSelector = new CustomStreamSelector(fileSystem, _logger); + + var tz = TZConvert.GetTimeZoneInfo("America/Chicago"); + var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(1); + audioStream.Language.ShouldBe("eng"); + } + + result.Subtitle.IsSome.ShouldBeFalse(); + } + + [Test] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match() + { + // saturday from 9pm-11pm + const string YAML = + """ + --- + items: + - audio_language: ["ja"] + subtitle_language: ["eng"] + content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)" + + - audio_language: ["eng"] + disable_subtitles: true + """; + + var fileSystem = new MockFileSystem(); + fileSystem.Initialize() + .WithFile(TestFileName).Which(f => f.HasStringContent(YAML)); + var streamSelector = new CustomStreamSelector(fileSystem, _logger); + + var tz = TZConvert.GetTimeZoneInfo("America/Chicago"); + var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(0); + audioStream.Language.ShouldBe("ja"); + } + + result.Subtitle.IsSome.ShouldBeTrue(); + + foreach (Subtitle subtitle in result.Subtitle) + { + subtitle.Id.ShouldBe(2); + subtitle.Language.ShouldBe("eng"); + } + } + + [Test] + [SetCulture("fr-FR")] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France() + { + // saturday from 9pm-11pm + const string YAML = + """ + --- + items: + - audio_language: ["ja"] + subtitle_language: ["eng"] + content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)" + + - audio_language: ["eng"] + disable_subtitles: true + """; + + var fileSystem = new MockFileSystem(); + fileSystem.Initialize() + .WithFile(TestFileName).Which(f => f.HasStringContent(YAML)); + var streamSelector = new CustomStreamSelector(fileSystem, _logger); + + var tz = TZConvert.GetTimeZoneInfo("America/Chicago"); + var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(0); + audioStream.Language.ShouldBe("ja"); + } + + result.Subtitle.IsSome.ShouldBeTrue(); + + foreach (Subtitle subtitle in result.Subtitle) + { + subtitle.Id.ShouldBe(2); + subtitle.Language.ShouldBe("eng"); + } + } + [Test] public async Task Should_Ignore_Blocked_Audio_Title() { diff --git a/ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs b/ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs index 150d183f3..276ed4956 100644 --- a/ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs +++ b/ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.IO.Abstractions; using System.IO.Enumeration; using ErsatzTV.Core.Domain; @@ -316,6 +317,7 @@ public class CustomStreamSelector(IFileSystem fileSystem, ILogger channel.Number, "channel_name" => channel.Name, "time_of_day_seconds" => contentStartTime.LocalDateTime.TimeOfDay.TotalSeconds, + "day_of_week" => GetLocalizedDayOfWeekIndex(contentStartTime), _ => e.Result }; }; @@ -341,4 +343,11 @@ public class CustomStreamSelector(IFileSystem fileSystem, ILogger