From 95ce4edf9b05cb2003968267ae993347abd2e302 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 25 Sep 2026 19:29:03 -0400 Subject: [PATCH] Show times in a configurable time zone SRG sends times with an offset (e.g. 13:55+02:00). They are stored as DateTime, which deserialisation converts to server-local time, and two places then wrote that straight into text: the "[dd.MM HH:mm]" prefix on upcoming livestream names and the timestamp in recording file names (DateTime.Now). On a server running in UTC both were two hours behind Swiss time, e.g. a qualifying starting at 13:55 was listed as 11:55. A new "Display Time Zone" setting (IANA id, e.g. Europe/Zurich) now decides the zone for both; empty keeps the server's zone, and an unknown id falls back to it rather than breaking the listing. Utilities/DisplayTime does the conversion. Also fixes PremiereDate on upcoming livestreams, which was set to that server-local value although Jellyfin expects UTC. It was only correct on UTC servers; it is now converted, like the other channel items. Verified on simulated UTC and Europe/Paris servers: the same instant gives 11:55 / 13:55 / 07:55 for server zone / Europe/Zurich / America/New_York, and PremiereDate is 11:55 UTC on both. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Channels/SrgChannelBase.cs | 4 +- .../Configuration/PluginConfiguration.cs | 8 ++++ .../Configuration/configPage.html | 7 ++++ .../Services/RecordingService.cs | 2 +- .../Utilities/DisplayTime.cs | 42 +++++++++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 Jellyfin.Plugin.SRFPlay/Utilities/DisplayTime.cs diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs b/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs index e45cbda..fc6b8a5 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs @@ -303,8 +303,8 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey if (matchingEvent?.ValidFrom != null) { var eventTime = matchingEvent.ValidFrom.Value; - item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}"; - item.PremiereDate = eventTime; + item.Name = $"[{DisplayTime.From(eventTime):dd.MM HH:mm}] {matchingEvent.Title}"; + item.PremiereDate = eventTime.ToUniversalTime(); } } } diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs index 138b142..65b749c 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs @@ -169,6 +169,14 @@ public class PluginConfiguration : BasePluginConfiguration /// public string RecordingOutputPath { get; set; } = string.Empty; + /// + /// Gets or sets the time zone used for times the plugin writes as text: the start time in + /// upcoming livestream names and the timestamp in recording file names. An IANA id such as + /// "Europe/Zurich"; empty uses the server's time zone. Dates Jellyfin shows itself (e.g. + /// PremiereDate) are stored as UTC and follow each viewer's own time zone regardless. + /// + public string DisplayTimeZone { get; set; } = string.Empty; + /// /// Gets or sets how many segments back from the live edge livestream playback should start. /// Injected as #EXT-X-START:TIME-OFFSET=-(N * targetDuration) into the live media diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html index 1d56931..1e18ab3 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html +++ b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html @@ -82,6 +82,11 @@
Directory where sport livestream recordings will be saved (requires ffmpeg)
+
+ + +
Time zone for start times in upcoming livestream names and for recording file names. Leave empty to use the server's time zone.
+
@@ -200,6 +205,7 @@ document.querySelector('#ProxyPassword').value = config.ProxyPassword || ''; document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || ''; document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || ''; + document.querySelector('#DisplayTimeZone').value = config.DisplayTimeZone || ''; document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3; document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false; document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false; @@ -231,6 +237,7 @@ config.ProxyPassword = document.querySelector('#ProxyPassword').value; config.PublicServerUrl = document.querySelector('#PublicServerUrl').value; config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value; + config.DisplayTimeZone = document.querySelector('#DisplayTimeZone').value.trim(); config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0; config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked; config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked; diff --git a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs index c07427a..8c647f8 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs @@ -451,7 +451,7 @@ public class RecordingService : IRecordingService, IDisposable // Build output file path var safeTitle = SanitizeFileName(entry.Title); - var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture); + var timestamp = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture); var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv"); entry.OutputPath = outputPath; diff --git a/Jellyfin.Plugin.SRFPlay/Utilities/DisplayTime.cs b/Jellyfin.Plugin.SRFPlay/Utilities/DisplayTime.cs new file mode 100644 index 0000000..b48eab0 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Utilities/DisplayTime.cs @@ -0,0 +1,42 @@ +using System; + +namespace Jellyfin.Plugin.SRFPlay.Utilities; + +/// +/// Converts times into the time zone the plugin writes into text (item names, file names). +/// SRG sends times with an offset, and DateTime deserialisation turns them into server-local +/// values, so without this the text follows whatever zone the server happens to run in. +/// +public static class DisplayTime +{ + /// + /// Converts a time to the configured display time zone (the server's if none is set). + /// + /// The time. Unspecified values are treated as server-local, like deserialised ones. + /// The same instant expressed in the display time zone. + public static DateTime From(DateTime value) + { + return TimeZoneInfo.ConvertTimeFromUtc(value.ToUniversalTime(), GetZone()); + } + + /// + /// Gets the current time in the display time zone. + /// + /// The current time in the display time zone. + public static DateTime Now() + { + return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, GetZone()); + } + + private static TimeZoneInfo GetZone() + { + var id = Plugin.Instance?.Configuration.DisplayTimeZone; + if (string.IsNullOrWhiteSpace(id)) + { + return TimeZoneInfo.Local; + } + + // An unknown id falls back to the server's zone rather than breaking listings. + return TimeZoneInfo.TryFindSystemTimeZoneById(id.Trim(), out var zone) ? zone : TimeZoneInfo.Local; + } +}