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)
|
if (matchingEvent?.ValidFrom != null)
|
||||||
{
|
{
|
||||||
var eventTime = matchingEvent.ValidFrom.Value;
|
var eventTime = matchingEvent.ValidFrom.Value;
|
||||||
item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}";
|
item.Name = $"[{DisplayTime.From(eventTime):dd.MM HH:mm}] {matchingEvent.Title}";
|
||||||
item.PremiereDate = eventTime;
|
item.PremiereDate = eventTime.ToUniversalTime();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
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>
|
/// <summary>
|
||||||
/// Gets or sets how many segments back from the live edge livestream playback should start.
|
/// 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
|
/// 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" />
|
<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 class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
||||||
</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">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
|
<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" />
|
<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('#ProxyPassword').value = config.ProxyPassword || '';
|
||||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||||
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||||
|
document.querySelector('#DisplayTimeZone').value = config.DisplayTimeZone || '';
|
||||||
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
||||||
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
||||||
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
|
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
|
||||||
@@ -231,6 +237,7 @@
|
|||||||
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
||||||
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
||||||
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
||||||
|
config.DisplayTimeZone = document.querySelector('#DisplayTimeZone').value.trim();
|
||||||
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
||||||
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
||||||
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
|
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ public class RecordingService : IRecordingService, IDisposable
|
|||||||
|
|
||||||
// Build output file path
|
// Build output file path
|
||||||
var safeTitle = SanitizeFileName(entry.Title);
|
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");
|
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
|
||||||
entry.OutputPath = outputPath;
|
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