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) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,14 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string DisplayTimeZone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many segments back from the live edge livestream playback should start.
|
||||
/// Injected as <c>#EXT-X-START:TIME-OFFSET=-(N * targetDuration)</c> into the live media
|
||||
|
||||
@@ -82,6 +82,11 @@
|
||||
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
||||
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="DisplayTimeZone">Display Time Zone</label>
|
||||
<input id="DisplayTimeZone" name="DisplayTimeZone" type="text" is="emby-input" placeholder="e.g., Europe/Zurich" />
|
||||
<div class="fieldDescription">Time zone for start times in upcoming livestream names and for recording file names. Leave empty to use the server's time zone.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
|
||||
<input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" />
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class DisplayTime
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a time to the configured display time zone (the server's if none is set).
|
||||
/// </summary>
|
||||
/// <param name="value">The time. Unspecified values are treated as server-local, like deserialised ones.</param>
|
||||
/// <returns>The same instant expressed in the display time zone.</returns>
|
||||
public static DateTime From(DateTime value)
|
||||
{
|
||||
return TimeZoneInfo.ConvertTimeFromUtc(value.ToUniversalTime(), GetZone());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current time in the display time zone.
|
||||
/// </summary>
|
||||
/// <returns>The current time in the display time zone.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user