diff --git a/CHANGELOG.md b/CHANGELOG.md index 5546c3604..67a5bf2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add *experimental* new streaming mode `HLS Segmenter V2` - In my initial testing, this streaming mode produces significantly fewer playback warnings/errors - If it tests well for others, it *may* replace the current `HLS Segmenter` in a future release +- Add setting to change XMLTV data from `Local` time zone to `UTC` + - This is needed because some clients (incorrectly) ignore time zone specifier and require UTC times ### Fixed - Fix antiforgery error caused by reusing existing browser tabs across docker container restarts diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs index 4cfaf5cf9..5d75ce935 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -1,10 +1,12 @@ using System.Globalization; using System.Xml; +using ErsatzTV.Application.Configuration; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Emby; using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Streaming; using ErsatzTV.Infrastructure.Data; @@ -22,6 +24,7 @@ public class RefreshChannelDataHandler : IRequestHandler { private readonly IDbContextFactory _dbContextFactory; private readonly ILocalFileSystem _localFileSystem; + private readonly IConfigElementRepository _configElementRepository; private readonly ILogger _logger; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; @@ -29,16 +32,20 @@ public class RefreshChannelDataHandler : IRequestHandler RecyclableMemoryStreamManager recyclableMemoryStreamManager, IDbContextFactory dbContextFactory, ILocalFileSystem localFileSystem, + IConfigElementRepository configElementRepository, ILogger logger) { _recyclableMemoryStreamManager = recyclableMemoryStreamManager; _dbContextFactory = dbContextFactory; _localFileSystem = localFileSystem; + _configElementRepository = configElementRepository; _logger = logger; } public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken) { + _logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber); + _localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder); string movieTemplateFileName = GetMovieTemplateFileName(); @@ -222,7 +229,7 @@ public class RefreshChannelDataHandler : IRequestHandler File.Move(tempFile, targetFile, true); } - private static async Task WritePlayoutXml( + private async Task WritePlayoutXml( RefreshChannelData request, List sorted, XmlTemplateContext templateContext, @@ -234,6 +241,10 @@ public class RefreshChannelDataHandler : IRequestHandler XmlMinifier minifier, XmlWriter xml) { + XmltvTimeZone xmltvTimeZone = await _configElementRepository + .GetValue(ConfigElementKey.XmltvTimeZone) + .IfNoneAsync(XmltvTimeZone.Local); + // skip all filler that isn't pre-roll var i = 0; while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None && @@ -278,13 +289,27 @@ public class RefreshChannelDataHandler : IRequestHandler PlayoutItem finishItem = sorted[finishIndex]; i = finishIndex; - string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) + DateTimeOffset startTime = xmltvTimeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero), + _ => startItem.StartOffset + }; + + DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch + { + (XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero), + (XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero), + (_, true) => displayItem.GuideFinishOffset!.Value, + (_, false) => finishItem.FinishOffset + }; + + string start = startTime + .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) + .Replace(":", string.Empty); + + string stop = stopTime + .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) .Replace(":", string.Empty); - string stop = displayItem.GuideFinishOffset.HasValue - ? displayItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty) - : finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); await WriteItemToXml( request, diff --git a/ErsatzTV.Application/Configuration/Commands/UpdateXmltvSettings.cs b/ErsatzTV.Application/Configuration/Commands/UpdateXmltvSettings.cs new file mode 100644 index 000000000..7df60a841 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdateXmltvSettings.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Configuration; + +public record UpdateXmltvSettings(XmltvSettingsViewModel XmltvSettings) : IRequest>; diff --git a/ErsatzTV.Application/Configuration/Commands/UpdateXmltvSettingsHandler.cs b/ErsatzTV.Application/Configuration/Commands/UpdateXmltvSettingsHandler.cs new file mode 100644 index 000000000..0537ba0a7 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdateXmltvSettingsHandler.cs @@ -0,0 +1,34 @@ +using System.Threading.Channels; +using ErsatzTV.Application.Channels; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Configuration; + +public class UpdateXmltvSettingsHandler( + IDbContextFactory dbContextFactory, + IConfigElementRepository configElementRepository, + ChannelWriter workerChannel) + : IRequestHandler> +{ + public async Task> Handle( + UpdateXmltvSettings request, + CancellationToken cancellationToken) => await ApplyUpdate(request.XmltvSettings); + + private async Task ApplyUpdate(XmltvSettingsViewModel xmltvSettings) + { + await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone); + + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); + + foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync()) + { + await workerChannel.WriteAsync(new RefreshChannelData(channelNumber)); + } + + return Unit.Default; + } +} \ No newline at end of file diff --git a/ErsatzTV.Application/Configuration/Queries/GetXmltvSettings.cs b/ErsatzTV.Application/Configuration/Queries/GetXmltvSettings.cs new file mode 100644 index 000000000..9b859cd1e --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetXmltvSettings.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Configuration; + +public record GetXmltvSettings : IRequest; diff --git a/ErsatzTV.Application/Configuration/Queries/GetXmltvSettingsHandler.cs b/ErsatzTV.Application/Configuration/Queries/GetXmltvSettingsHandler.cs new file mode 100644 index 000000000..a55a6a90a --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetXmltvSettingsHandler.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; + +namespace ErsatzTV.Application.Configuration; + +public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepository) + : IRequestHandler +{ + public async Task Handle(GetXmltvSettings request, CancellationToken cancellationToken) + { + Option maybeTimeZone = + await configElementRepository.GetValue(ConfigElementKey.XmltvTimeZone); + + return new XmltvSettingsViewModel + { + TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local) + }; + } +} diff --git a/ErsatzTV.Application/Configuration/XmltvSettingsViewModel.cs b/ErsatzTV.Application/Configuration/XmltvSettingsViewModel.cs new file mode 100644 index 000000000..c7bd5128f --- /dev/null +++ b/ErsatzTV.Application/Configuration/XmltvSettingsViewModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Application.Configuration; + +public class XmltvSettingsViewModel +{ + public XmltvTimeZone TimeZone { get; set; } +} diff --git a/ErsatzTV.Application/Configuration/XmltvTimeZone.cs b/ErsatzTV.Application/Configuration/XmltvTimeZone.cs new file mode 100644 index 000000000..31e22aeda --- /dev/null +++ b/ErsatzTV.Application/Configuration/XmltvTimeZone.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Application.Configuration; + +public enum XmltvTimeZone +{ + Local = 0, + Utc = 1 +} diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index 84daf26c2..a501cba7f 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -42,4 +42,5 @@ public class ConfigElementKey public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval"); public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build"); public static ConfigElementKey PlayoutSkipMissingItems => new("playout.skip_missing_items"); + public static ConfigElementKey XmltvTimeZone => new("xmltv.time_zone"); } diff --git a/ErsatzTV/Pages/Settings.razor b/ErsatzTV/Pages/Settings.razor index 055d21597..999aca8a2 100644 --- a/ErsatzTV/Pages/Settings.razor +++ b/ErsatzTV/Pages/Settings.razor @@ -291,6 +291,27 @@ + + + + Program Guide (XMLTV) + + + + + Local + UTC + + + + + Save Settings + + + @@ -312,6 +333,7 @@ private int _libraryRefreshInterval; private PlayoutSettingsViewModel _playoutSettings = new(); private GeneralSettingsViewModel _generalSettings = new(); + private XmltvSettingsViewModel _xmltvSettings = new(); public void Dispose() { @@ -336,6 +358,7 @@ _playoutSettings = await Mediator.Send(new GetPlayoutSettings(), _cts.Token); _playoutSuccess = _playoutSettings.DaysToBuild > 0; _generalSettings = await Mediator.Send(new GetGeneralSettings(), _cts.Token); + _xmltvSettings = await Mediator.Send(new GetXmltvSettings(), _cts.Token); await RefreshCustomResolutions(); } @@ -420,6 +443,18 @@ }, Right: _ => Snackbar.Add("Successfully saved general settings", Severity.Success)); } + + private async Task SaveXmltvSettings() + { + Either result = await Mediator.Send(new UpdateXmltvSettings(_xmltvSettings), _cts.Token); + result.Match( + Left: error => + { + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Unexpected error saving xmltv settings: {Error}", error.Value); + }, + Right: _ => Snackbar.Add("Successfully saved xmltv settings", Severity.Success)); + } private async Task RefreshCustomResolutions() => _customResolutions = await Mediator.Send(new GetAllResolutions(), _cts.Token) .Map(list => list.Filter(r => r.IsCustom).ToList()); diff --git a/ErsatzTV/Program.cs b/ErsatzTV/Program.cs index 7ef3fc031..d79b918e6 100644 --- a/ErsatzTV/Program.cs +++ b/ErsatzTV/Program.cs @@ -79,6 +79,7 @@ public class Program .MinimumLevel.Override("ErsatzTV.FFmpeg", LoggingLevelSwitches.StreamingLevelSwitch) .MinimumLevel.Override("ErsatzTV.Core.FFmpeg.FFmpegLibraryProcessService", LoggingLevelSwitches.StreamingLevelSwitch) .MinimumLevel.Override("ErsatzTV.Controllers.IptvController", LoggingLevelSwitches.StreamingLevelSwitch) + .MinimumLevel.Override("ErsatzTV.Controllers.InternalController", LoggingLevelSwitches.StreamingLevelSwitch) // http .MinimumLevel.Override("Serilog.AspNetCore.RequestLoggingMiddleware", LoggingLevelSwitches.HttpLevelSwitch)