From 378ba937b6e5f79a065c56b065d57c192a634fb7 Mon Sep 17 00:00:00 2001 From: Thomas Jones Date: Tue, 23 Sep 2025 13:17:52 -0600 Subject: [PATCH 01/99] Added in pragma warning disable for CA1815 warning. Co-authored-by: Derpipose <90276123+Derpipose@users.noreply.github.com> --- Emby.Naming/AudioBook/AudioBookNameParserResult.cs | 2 ++ Emby.Naming/Video/CleanDateTimeResult.cs | 2 ++ MediaBrowser.Model/Drawing/ImageDimensions.cs | 1 + 3 files changed, 5 insertions(+) diff --git a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs index 3f2d7b2b0b..de78e75a91 100644 --- a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs +++ b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1815 + namespace Emby.Naming.AudioBook { /// diff --git a/Emby.Naming/Video/CleanDateTimeResult.cs b/Emby.Naming/Video/CleanDateTimeResult.cs index c675a19d0f..e367f92213 100644 --- a/Emby.Naming/Video/CleanDateTimeResult.cs +++ b/Emby.Naming/Video/CleanDateTimeResult.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1815 + namespace Emby.Naming.Video { /// diff --git a/MediaBrowser.Model/Drawing/ImageDimensions.cs b/MediaBrowser.Model/Drawing/ImageDimensions.cs index f84fe68305..49528ef8ae 100644 --- a/MediaBrowser.Model/Drawing/ImageDimensions.cs +++ b/MediaBrowser.Model/Drawing/ImageDimensions.cs @@ -1,4 +1,5 @@ #pragma warning disable CS1591 +#pragma warning disable CA1815 using System.Globalization; From a014cb538e0b833057e141c5671b0a86d643d188 Mon Sep 17 00:00:00 2001 From: Thomas Jones Date: Tue, 23 Sep 2025 14:18:15 -0600 Subject: [PATCH 02/99] Fixed 3 different CA1051 warnings. Changed them to auto properties and rearranged the members to fit styling rules. Co-authored-by: Derpipose <90276123+Derpipose@users.noreply.github.com> --- .../MediaEncoding/EncodingJobInfo.cs | 7 ++++--- .../Net/BasePeriodicWebSocketListener.cs | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index ef912f42c3..43680f5c01 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -23,9 +23,6 @@ namespace MediaBrowser.Controller.MediaEncoding { private static readonly char[] _separators = ['|', ',']; - public int? OutputAudioBitrate; - public int? OutputAudioChannels; - private TranscodeReason? _transcodeReasons = null; public EncodingJobInfo(TranscodingJobType jobType) @@ -37,6 +34,10 @@ namespace MediaBrowser.Controller.MediaEncoding SupportedSubtitleCodecs = Array.Empty(); } + public int? OutputAudioBitrate { get; set; } + + public int? OutputAudioChannels { get; set; } + public TranscodeReason TranscodeReasons { get diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 1e0d77fe51..6b1eac8047 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -40,11 +40,6 @@ namespace MediaBrowser.Controller.Net /// private readonly List<(IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)> _activeConnections = new(); - /// - /// The logger. - /// - protected readonly ILogger> Logger; - private readonly Task _messageConsumerTask; protected BasePeriodicWebSocketListener(ILogger> logger) @@ -56,6 +51,11 @@ namespace MediaBrowser.Controller.Net _messageConsumerTask = HandleMessages(); } + /// + /// Gets the Logger. + /// + protected ILogger> Logger { get; } + /// /// Gets the type used for the messages sent to the client. /// From 893188ab287abe7647d4f0dfd9cbff0f9cf5adcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20CORBASSON?= Date: Mon, 16 Feb 2026 17:05:27 +0100 Subject: [PATCH 03/99] Add the filename to the exception's trace to facilitate error resolution (see #12254, #9508, ...) --- .../Serialization/MyXmlSerializer.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index aa5fbbdf73..5c9a94cd36 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -85,9 +85,17 @@ namespace Emby.Server.Implementations.Serialization /// System.Object. public object? DeserializeFromFile(Type type, string file) { - using (var stream = File.OpenRead(file)) + try { - return DeserializeFromStream(type, stream); + using (var stream = File.OpenRead(file)) + { + return DeserializeFromStream(type, stream); + } + } + catch (Exception ex) + { + ex.Data.Add("Filename", file); + throw; } } From e49d71707c5f9f46fca373922a1ac1893cfc6ad5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 11 Feb 2026 09:44:37 +0100 Subject: [PATCH 04/99] Fix EPG issues --- Jellyfin.Api/Controllers/LiveTvController.cs | 4 +- .../LiveTv/ITunerHostManager.cs | 6 ++ .../LiveTv/LiveTvChannel.cs | 2 +- src/Jellyfin.LiveTv/DefaultLiveTvService.cs | 5 +- .../Listings/ListingsManager.cs | 34 +++++++++++ .../Listings/SchedulesDirect.cs | 59 +++++++++++-------- .../Listings/XmlTvListingsProvider.cs | 53 ++++++++++++----- .../TunerHosts/TunerHostManager.cs | 28 +++++++++ 8 files changed, 146 insertions(+), 45 deletions(-) diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 94f62a0713..736ba03931 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -992,9 +992,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DeleteTunerHost([FromQuery] string? id) { - var config = _configurationManager.GetConfiguration("livetv"); - config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); - _configurationManager.SaveConfiguration("livetv", config); + _tunerHostManager.DeleteTunerHost(id); return NoContent(); } diff --git a/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs b/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs index 8247066cc9..68e61f3cc4 100644 --- a/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs +++ b/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs @@ -37,6 +37,12 @@ public interface ITunerHostManager /// The s. IAsyncEnumerable DiscoverTuners(bool newDevicesOnly); + /// + /// Deletes a tuner host by id, cleans up associated caches, and triggers a guide refresh. + /// + /// The tuner host id to delete. + void DeleteTunerHost(string? id); + /// /// Scans for tuner devices that have changed URLs. /// diff --git a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs index b10e77e10a..aee4691cdf 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs @@ -109,7 +109,7 @@ namespace MediaBrowser.Controller.LiveTv { if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number)) { - return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty); + return string.Format(CultureInfo.InvariantCulture, "{0:0000000000.00000}", number) + "-" + (Name ?? string.Empty); } return (Number ?? string.Empty) + "-" + (Name ?? string.Empty); diff --git a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs index d8f873abe6..d477bc3713 100644 --- a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs +++ b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs @@ -774,7 +774,10 @@ namespace Jellyfin.LiveTv } } - SearchForDuplicateShowIds(enabledTimersForSeries); + if (seriesTimer.SkipEpisodesInLibrary) + { + SearchForDuplicateShowIds(enabledTimersForSeries); + } if (deleteInvalidTimers) { diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index 39c2bd375b..a37204cc57 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -74,6 +75,9 @@ public class ListingsManager : IListingsManager } _config.SaveConfiguration("livetv", config); + + InvalidateListingsProviderCache(info.Id); + _taskManager.CancelIfRunningAndQueue(); return info; @@ -87,6 +91,12 @@ public class ListingsManager : IListingsManager config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); _config.SaveConfiguration("livetv", config); + + if (!string.IsNullOrEmpty(id)) + { + InvalidateListingsProviderCache(id); + } + _taskManager.CancelIfRunningAndQueue(); } @@ -322,6 +332,30 @@ public class ListingsManager : IListingsManager return channelId; } + private void InvalidateListingsProviderCache(string providerId) + { + // Clear in-memory EPG channel cache for this provider + _epgChannels.TryRemove(providerId, out _); + + // Delete the cached XMLTV file so a fresh copy is downloaded + var cachePath = _config.CommonApplicationPaths?.CachePath; + if (!string.IsNullOrEmpty(cachePath)) + { + var xmltvCacheFile = Path.Combine(cachePath, "xmltv", providerId + ".xml"); + if (File.Exists(xmltvCacheFile)) + { + try + { + File.Delete(xmltvCacheFile); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", providerId); + } + } + } + } + private async Task GetEpgChannels( IListingsProvider provider, ListingsProviderInfo info, diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index d6f15906ef..939fd0f66d 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -149,7 +149,7 @@ namespace Jellyfin.LiveTv.Listings var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays); if (willBeCached && images is not null) { - var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); + var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId); if (imageIndex > -1) { var programEntry = programDict[schedule.ProgramId]; @@ -458,32 +458,32 @@ namespace Jellyfin.LiveTv.Listings return []; } - StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); - foreach (var i in programIds) + // SD API accepts max 500 program IDs per request + const int BatchSize = 500; + var results = new List(); + for (int i = 0; i < programIds.Count; i += BatchSize) { - str.Append('"') - .Append(i[..10]) - .Append("\","); + var batch = programIds.Skip(i).Take(BatchSize); + + using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs"); + message.Headers.TryAddWithoutValidation("token", token); + message.Content = JsonContent.Create(batch, options: _jsonOptions); + + try + { + var batchResult = await Request>(message, true, info, cancellationToken).ConfigureAwait(false); + if (batchResult is not null) + { + results.AddRange(batchResult); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image info from schedules direct"); + } } - // Remove last , - str.Length--; - str.Append(']'); - - using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs"); - message.Headers.TryAddWithoutValidation("token", token); - message.Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json); - - try - { - return await Request>(message, true, info, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image info from schedules direct"); - - return []; - } + return results; } public async Task> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) @@ -547,7 +547,7 @@ namespace Jellyfin.LiveTv.Listings } // Avoid hammering SD - if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1) + if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 30) { return null; } @@ -579,7 +579,7 @@ namespace Jellyfin.LiveTv.Listings } catch (HttpRequestException ex) { - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + if (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 400 && (int)ex.StatusCode.Value < 500) { _tokens.Clear(); _lastErrorResponse = DateTime.UtcNow; @@ -702,6 +702,13 @@ namespace Jellyfin.LiveTv.Listings return false; } + // Clear tokens on any client error to avoid hammering SD with stale credentials + if (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 400 && (int)ex.StatusCode.Value < 500) + { + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } + throw; } } diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index 7938b7a6e4..0b73c6776f 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -79,25 +79,39 @@ namespace Jellyfin.LiveTv.Listings Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); } - if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + try { - _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); - - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); - var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path; - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) + if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); + + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path; + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } + } + else + { + var stream = AsyncFile.OpenRead(info.Path); + await using (stream.ConfigureAwait(false)) + { + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } } } - else + catch (Exception ex) { - var stream = AsyncFile.OpenRead(info.Path); - await using (stream.ConfigureAwait(false)) + _logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path); + + if (File.Exists(cacheFile)) { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + File.Delete(cacheFile); } + + throw; } } @@ -130,9 +144,20 @@ namespace Jellyfin.LiveTv.Listings { await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } - - return file; } + + var fileInfo = new FileInfo(file); + if (!fileInfo.Exists || fileInfo.Length == 0) + { + if (fileInfo.Exists) + { + File.Delete(file); + } + + throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl); + } + + return file; } public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs index d67f77bc0a..4043d7399e 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Text.Json; using System.Threading; @@ -99,6 +100,33 @@ public class TunerHostManager : ITunerHostManager return info; } + /// + public void DeleteTunerHost(string? id) + { + var config = _config.GetLiveTvConfiguration(); + config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); + _config.SaveConfiguration("livetv", config); + + // Clean up the disk cache file for this tuner + if (!string.IsNullOrEmpty(id)) + { + var channelCacheFile = Path.Combine(_config.CommonApplicationPaths.CachePath, id + "_channels"); + if (File.Exists(channelCacheFile)) + { + try + { + File.Delete(channelCacheFile); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Error deleting channel cache file for tuner {TunerId}", id); + } + } + } + + _taskManager.CancelIfRunningAndQueue(); + } + /// public async IAsyncEnumerable DiscoverTuners(bool newDevicesOnly) { From b0eec00e1cda109e5c6720f054932993108f0549 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 20 Feb 2026 14:58:12 +0100 Subject: [PATCH 05/99] Properly handle SD internal error codes --- .../Listings/SchedulesDirect.cs | 101 +++++++++++++----- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 939fd0f66d..2ca42c89ef 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -42,6 +42,7 @@ namespace Jellyfin.LiveTv.Listings private readonly ConcurrentDictionary _tokens = new(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private DateTime _lastErrorResponse; + private bool _accountError; private bool _disposed = false; public SchedulesDirect( @@ -546,7 +547,13 @@ namespace Jellyfin.LiveTv.Listings return null; } - // Avoid hammering SD + // Permanent account error — SD is disabled for this server lifetime. + if (_accountError) + { + return null; + } + + // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout) if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 30) { return null; @@ -579,7 +586,13 @@ namespace Jellyfin.LiveTv.Listings } catch (HttpRequestException ex) { - if (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 400 && (int)ex.StatusCode.Value < 500) + // For 4xx errors not already handled by Request's SD code logic + // (e.g. unparseable response from the /token endpoint), apply a + // temporary backoff to avoid hammering SD. + if (!_accountError + && ex.StatusCode.HasValue + && (int)ex.StatusCode.Value >= 400 + && (int)ex.StatusCode.Value < 500) { _tokens.Clear(); _lastErrorResponse = DateTime.UtcNow; @@ -605,27 +618,70 @@ namespace Jellyfin.LiveTv.Listings return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); } - if (!enableRetry || (int)response.StatusCode >= 500) - { - _logger.LogError( - "Request to {Url} failed with response {Response}", - message.RequestUri, - await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - throw new HttpRequestException( - string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), - null, - response.StatusCode); + // Try to extract the Schedules Direct error code from the response body. + int? sdCode = null; + try + { + using var doc = JsonDocument.Parse(responseBody); + if (doc.RootElement.TryGetProperty("code", out var codeProp) && codeProp.TryGetInt32(out var parsedCode)) + { + sdCode = parsedCode; + } + } + catch (JsonException) + { + // Response body is not valid JSON; sdCode stays null. } - _tokens.Clear(); - using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri); - retryMessage.Content = message.Content; - retryMessage.Headers.TryAddWithoutValidation( - "token", - await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + _logger.LogError( + "Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}", + message.RequestUri, + (int)response.StatusCode, + sdCode?.ToString(CultureInfo.InvariantCulture) ?? "N/A", + responseBody); - return await Request(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false); + if (sdCode is 4001 or 4003 or 4004 or 4005 or 4008) + { + // Permanent account errors — disable SD for this server lifetime. + // 4001=invalid user + // 4003=invalid hash + // 4004=account locked/disabled + // 4005=account expired + // 4008=password required + _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart", sdCode); + _tokens.Clear(); + _accountError = true; + } + else if (sdCode is 4009 or 4010) + { + // Transient login errors — back off for 30 minutes, then allow retry. + // 4009=max login attempts + // 4010=temporary lockout + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } + else if (enableRetry + && (int)response.StatusCode < 500 + && (sdCode == 4006 || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is null))) + { + // 4006 = token expired — clear tokens and retry with a fresh token. + // Also retry on 403 with no parseable SD code (legacy/unexpected auth failure). + _tokens.Clear(); + using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri); + retryMessage.Content = message.Content; + retryMessage.Headers.TryAddWithoutValidation( + "token", + await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + + return await Request(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false); + } + + throw new HttpRequestException( + string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), + null, + response.StatusCode); } private async Task GetTokenInternal( @@ -702,13 +758,6 @@ namespace Jellyfin.LiveTv.Listings return false; } - // Clear tokens on any client error to avoid hammering SD with stale credentials - if (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 400 && (int)ex.StatusCode.Value < 500) - { - _tokens.Clear(); - _lastErrorResponse = DateTime.UtcNow; - } - throw; } } From 679664ca28c9ac49f30ba73e2aaa4ad0684d40dd Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 20 Feb 2026 15:14:03 +0100 Subject: [PATCH 06/99] Add early returns --- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 2ca42c89ef..083858ebaf 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -454,7 +454,7 @@ namespace Jellyfin.LiveTv.Listings { var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - if (programIds.Count == 0) + if (string.IsNullOrEmpty(token) || programIds.Count == 0) { return []; } @@ -795,7 +795,10 @@ namespace Jellyfin.LiveTv.Listings var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - ArgumentException.ThrowIfNullOrEmpty(token); + if (string.IsNullOrEmpty(token)) + { + return []; + } using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); options.Headers.TryAddWithoutValidation("token", token); From c4c3e9ea4d8fa96cbddf6a12f7ec2d48ed181d2b Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 21 Feb 2026 11:40:18 +0100 Subject: [PATCH 07/99] Fix batch requests --- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 083858ebaf..54e4d64eb8 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -466,7 +466,7 @@ namespace Jellyfin.LiveTv.Listings { var batch = programIds.Skip(i).Take(BatchSize); - using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs"); + using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/"); message.Headers.TryAddWithoutValidation("token", token); message.Content = JsonContent.Create(batch, options: _jsonOptions); From 97340edf028ce830c89199ba00fcd3a953215a81 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 21 Feb 2026 16:47:08 +0100 Subject: [PATCH 08/99] Fix image failure response handling in batch endpoint --- .../ImageDataArrayConverter.cs | 42 +++++++++++++++++++ .../SchedulesDirectDtos/ShowImagesDto.cs | 1 + 2 files changed, 43 insertions(+) create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs new file mode 100644 index 0000000000..cb5ea1e684 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos; + +/// +/// Converter for the data field in SD image responses. +/// The Schedules Direct API may return a non-array value (e.g. a string error message) +/// instead of the expected image data array for programs with errors. +/// This converter returns an empty list for any non-array value. +/// +public sealed class ImageDataArrayConverter : JsonConverter> +{ + /// + public override IReadOnlyList Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.StartArray) + { + var result = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var item = JsonSerializer.Deserialize(ref reader, options); + if (item is not null) + { + result.Add(item); + } + } + + return result; + } + + // Not an array (string error, null, object, etc.) — skip and return empty. + reader.Skip(); + return []; + } + + /// + public override void Write(Utf8JsonWriter writer, IReadOnlyList value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, options); +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs index 523900a96a..8db75ef0b5 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs @@ -19,6 +19,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the list of data. /// [JsonPropertyName("data")] + [JsonConverter(typeof(ImageDataArrayConverter))] public IReadOnlyList Data { get; set; } = Array.Empty(); } } From d156e04c9a2b16d38aede38f0de773a4d128e48f Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 21 Feb 2026 22:56:53 +0100 Subject: [PATCH 09/99] Fix Skipping --- .../Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs index cb5ea1e684..ceb743f795 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs @@ -32,7 +32,7 @@ public sealed class ImageDataArrayConverter : JsonConverter Date: Sun, 22 Feb 2026 11:14:15 +0100 Subject: [PATCH 10/99] Handle 5002, 5003 and add caches --- Jellyfin.Api/Controllers/LiveTvController.cs | 43 +----- .../LiveTv/ISchedulesDirectService.cs | 17 +++ .../LiveTvServiceCollectionExtensions.cs | 4 +- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 59 ++++++-- .../Listings/SchedulesDirect.cs | 137 +++++++++++++++++- 5 files changed, 211 insertions(+), 49 deletions(-) create mode 100644 MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 736ba03931..03c51a86ed 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Net.Http; using System.Net.Mime; using System.Security.Cryptography; using System.Text; @@ -18,8 +17,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -49,12 +46,11 @@ public class LiveTvController : BaseJellyfinApiController private readonly IListingsManager _listingsManager; private readonly IRecordingsManager _recordingsManager; private readonly IUserManager _userManager; - private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IMediaSourceManager _mediaSourceManager; - private readonly IConfigurationManager _configurationManager; private readonly ITranscodeManager _transcodeManager; + private readonly ISchedulesDirectService _schedulesDirectService; /// /// Initializes a new instance of the class. @@ -65,12 +61,11 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public LiveTvController( ILiveTvManager liveTvManager, IGuideManager guideManager, @@ -78,12 +73,11 @@ public class LiveTvController : BaseJellyfinApiController IListingsManager listingsManager, IRecordingsManager recordingsManager, IUserManager userManager, - IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IDtoService dtoService, IMediaSourceManager mediaSourceManager, - IConfigurationManager configurationManager, - ITranscodeManager transcodeManager) + ITranscodeManager transcodeManager, + ISchedulesDirectService schedulesDirectService) { _liveTvManager = liveTvManager; _guideManager = guideManager; @@ -91,12 +85,11 @@ public class LiveTvController : BaseJellyfinApiController _listingsManager = listingsManager; _recordingsManager = recordingsManager; _userManager = userManager; - _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; _dtoService = dtoService; _mediaSourceManager = mediaSourceManager; - _configurationManager = configurationManager; _transcodeManager = transcodeManager; + _schedulesDirectService = schedulesDirectService; } /// @@ -344,20 +337,6 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.LiveTvAccess)] [Obsolete("This endpoint is obsolete.")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")] public ActionResult> GetRecordingsSeries( [FromQuery] string? channelId, [FromQuery] Guid? userId, @@ -387,7 +366,6 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.LiveTvAccess)] [Obsolete("This endpoint is obsolete.")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] public ActionResult> GetRecordingGroups([FromQuery] Guid? userId) { return new QueryResult(); @@ -832,7 +810,6 @@ public class LiveTvController : BaseJellyfinApiController [HttpPost("Timers/{timerId}")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] public async Task UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) { await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); @@ -922,7 +899,6 @@ public class LiveTvController : BaseJellyfinApiController [HttpPost("SeriesTimers/{timerId}")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] public async Task UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) { await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); @@ -1083,13 +1059,8 @@ public class LiveTvController : BaseJellyfinApiController [ProducesFile(MediaTypeNames.Application.Json)] public async Task GetSchedulesDirectCountries() { - var client = _httpClientFactory.CreateClient(NamedClient.Default); - // https://json.schedulesdirect.org/20141201/available/countries - // Can't dispose the response as it's required up the call chain. - var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries")) - .ConfigureAwait(false); - - return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); + var bytes = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false); + return File(bytes, MediaTypeNames.Application.Json); } /// diff --git a/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs b/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs new file mode 100644 index 0000000000..496a2c4c55 --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.LiveTv; + +/// +/// Provides Schedules Direct specific operations. +/// +public interface ISchedulesDirectService +{ + /// + /// Gets the available countries from the Schedules Direct API, using a file cache. + /// + /// The cancellation token. + /// The raw JSON response bytes. + Task GetAvailableCountries(CancellationToken cancellationToken); +} diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index ed72badbc0..0c2abe8beb 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -40,7 +40,9 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(s => s.GetRequiredService()); services.AddSingleton(); } } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index ac59a6d125..7e1992baf2 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -39,6 +39,11 @@ public class GuideManager : IGuideManager private readonly IRecordingsManager _recordingsManager; private readonly LiveTvDtoService _tvDtoService; + /// + /// UTC date when the SD image download limit was hit. Cleared after 00:00 UTC rollover. + /// + private DateTime? _sdImageLimitHitDate; + /// /// Amount of days images are pre-cached from external sources. /// @@ -721,6 +726,20 @@ public class GuideManager : IGuideManager return false; } + private bool IsSdImageLimitActive() + { + // The SD image counter resets daily at 00:00 UTC. + // If we recorded a limit hit on a previous UTC date, clear it. + var hitDate = _sdImageLimitHitDate; + if (hitDate.HasValue && hitDate.Value.Date < DateTime.UtcNow.Date) + { + _sdImageLimitHitDate = null; + return false; + } + + return hitDate.HasValue; + } + private async Task PreCacheImages(IReadOnlyList programs, DateTime maxCacheDate) { await Parallel.ForEachAsync( @@ -738,19 +757,39 @@ public class GuideManager : IGuideManager } var imageInfo = program.ImageInfos[i]; - if (!imageInfo.IsLocalFile) + if (imageInfo.IsLocalFile) { - _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path); - try + continue; + } + + // Skip SD downloads once the daily limit has been hit. + if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase) + && IsSdImageLimitActive()) + { + continue; + } + + _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path); + try + { + program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal( + program, + imageInfo, + imageIndex: 0, + removeOnFailure: false) + .ConfigureAwait(false); + } + catch (Exception ex) + { + if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase) + && !_sdImageLimitHitDate.HasValue) { - program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal( - program, - imageInfo, - imageIndex: 0, - removeOnFailure: false) - .ConfigureAwait(false); + _sdImageLimitHitDate = DateTime.UtcNow; + _logger.LogWarning( + "Schedules Direct image download failed for {Url}. Daily download limit may have been reached (resets at 00:00 UTC). Skipping remaining SD images until reset", + imageInfo.Path); } - catch (Exception ex) + else if (!imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path); } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 54e4d64eb8..39ad746877 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; @@ -21,6 +22,7 @@ using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.LiveTv.Guide; using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; +using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.LiveTv; @@ -31,12 +33,14 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.LiveTv.Listings { - public class SchedulesDirect : IListingsProvider, IDisposable + public class SchedulesDirect : IListingsProvider, ISchedulesDirectService, IDisposable { private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + private const int CountryCacheDays = 7; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; + private readonly IApplicationPaths _appPaths; private readonly AsyncNonKeyedLocker _tokenLock = new(1); private readonly ConcurrentDictionary _tokens = new(); @@ -45,17 +49,25 @@ namespace Jellyfin.LiveTv.Listings private bool _accountError; private bool _disposed = false; + private byte[] _countriesCache; + private DateTime? _dailyLimitHitDate; + public SchedulesDirect( ILogger logger, - IHttpClientFactory httpClientFactory) + IHttpClientFactory httpClientFactory, + IApplicationPaths appPaths) { _logger = logger; _httpClientFactory = httpClientFactory; + _appPaths = appPaths; + _dailyLimitHitDate = LoadDailyLimitHitDate(); } /// public string Name => "Schedules Direct"; + private string DailyLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-daily-limit.txt"); + /// public string Type => nameof(SchedulesDirect); @@ -553,6 +565,19 @@ namespace Jellyfin.LiveTv.Listings return null; } + // Daily usage limit hit (e.g. 5003) — wait until the SD counter resets at 00:00 UTC. + if (_dailyLimitHitDate.HasValue) + { + if (_dailyLimitHitDate.Value.Date < DateTime.UtcNow.Date) + { + ClearDailyLimitHitDate(); + } + else + { + return null; + } + } + // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout) if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 30) { @@ -662,6 +687,13 @@ namespace Jellyfin.LiveTv.Listings _tokens.Clear(); _lastErrorResponse = DateTime.UtcNow; } + else if (sdCode is 5002 or 5003) + { + // Daily usage limits — stop requests until SD resets at 00:00 UTC. + // 5002=max image downloads + // 5003=max schedule/metadata requests + SetDailyLimitHitDate(); + } else if (enableRetry && (int)response.StatusCode < 500 && (sdCode == 4006 || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is null))) @@ -762,6 +794,107 @@ namespace Jellyfin.LiveTv.Listings } } + /// + public async Task GetAvailableCountries(CancellationToken cancellationToken) + { + if (_countriesCache is not null) + { + return _countriesCache; + } + + var cachePath = Path.Combine(_appPaths.CachePath, "sd-countries.json"); + + if (File.Exists(cachePath) + && DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath) < TimeSpan.FromDays(CountryCacheDays)) + { + try + { + _countriesCache = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false); + return _countriesCache; + } + catch (IOException) + { + // Corrupt or unreadable — delete and re-fetch. + TryDeleteFile(cachePath); + } + } + + var client = _httpClientFactory.CreateClient(NamedClient.Default); + using var response = await client.GetAsync(new Uri(ApiUrl + "/available/countries"), cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); + await File.WriteAllBytesAsync(cachePath, bytes, cancellationToken).ConfigureAwait(false); + + _countriesCache = bytes; + return bytes; + } + + private DateTime? LoadDailyLimitHitDate() + { + var path = DailyLimitFilePath; + if (!File.Exists(path)) + { + return null; + } + + try + { + var text = File.ReadAllText(path).Trim(); + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date)) + { + if (date.Date < DateTime.UtcNow.Date) + { + // Expired — clean up. + File.Delete(path); + return null; + } + + return date; + } + } + catch (IOException) + { + // Corrupt or unreadable — delete and reset. + TryDeleteFile(path); + } + + return null; + } + + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + // Best effort. + } + } + + private void SetDailyLimitHitDate() + { + _dailyLimitHitDate = DateTime.UtcNow; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(DailyLimitFilePath)!); + File.WriteAllText(DailyLimitFilePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to persist SD daily limit hit date"); + } + } + + private void ClearDailyLimitHitDate() + { + _dailyLimitHitDate = null; + TryDeleteFile(DailyLimitFilePath); + } + public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) { if (validateLogin) From ed43ad09688d11ed09b9b45be409455c33bc0e6a Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 22 Feb 2026 11:32:55 +0100 Subject: [PATCH 11/99] Persistence --- .../Listings/SchedulesDirect.cs | 100 ++++++++++-------- 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 39ad746877..04589b3a8d 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -50,7 +50,8 @@ namespace Jellyfin.LiveTv.Listings private bool _disposed = false; private byte[] _countriesCache; - private DateTime? _dailyLimitHitDate; + private DateTime? _imageLimitHitDate; + private DateTime? _metadataLimitHitDate; public SchedulesDirect( ILogger logger, @@ -60,13 +61,16 @@ namespace Jellyfin.LiveTv.Listings _logger = logger; _httpClientFactory = httpClientFactory; _appPaths = appPaths; - _dailyLimitHitDate = LoadDailyLimitHitDate(); + _imageLimitHitDate = LoadDailyLimitFile(ImageLimitFilePath); + _metadataLimitHitDate = LoadDailyLimitFile(MetadataLimitFilePath); } /// public string Name => "Schedules Direct"; - private string DailyLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-daily-limit.txt"); + private string ImageLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-image-limit.txt"); + + private string MetadataLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-metadata-limit.txt"); /// public string Type => nameof(SchedulesDirect); @@ -89,6 +93,11 @@ namespace Jellyfin.LiveTv.Listings public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) { + if (IsDailyLimitActive(ref _metadataLimitHitDate, MetadataLimitFilePath)) + { + return []; + } + ArgumentException.ThrowIfNullOrEmpty(channelId); // Normalize incoming input @@ -464,6 +473,11 @@ namespace Jellyfin.LiveTv.Listings IReadOnlyList programIds, CancellationToken cancellationToken) { + if (IsDailyLimitActive(ref _imageLimitHitDate, ImageLimitFilePath)) + { + return []; + } + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(token) || programIds.Count == 0) @@ -565,19 +579,6 @@ namespace Jellyfin.LiveTv.Listings return null; } - // Daily usage limit hit (e.g. 5003) — wait until the SD counter resets at 00:00 UTC. - if (_dailyLimitHitDate.HasValue) - { - if (_dailyLimitHitDate.Value.Date < DateTime.UtcNow.Date) - { - ClearDailyLimitHitDate(); - } - else - { - return null; - } - } - // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout) if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 30) { @@ -687,12 +688,15 @@ namespace Jellyfin.LiveTv.Listings _tokens.Clear(); _lastErrorResponse = DateTime.UtcNow; } - else if (sdCode is 5002 or 5003) + else if (sdCode is 5002) { - // Daily usage limits — stop requests until SD resets at 00:00 UTC. - // 5002=max image downloads - // 5003=max schedule/metadata requests - SetDailyLimitHitDate(); + // Max image downloads — stop image requests until SD resets at 00:00 UTC. + SetDailyLimitHitDate(ref _imageLimitHitDate, ImageLimitFilePath); + } + else if (sdCode is 5003) + { + // Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC. + SetDailyLimitHitDate(ref _metadataLimitHitDate, MetadataLimitFilePath); } else if (enableRetry && (int)response.StatusCode < 500 @@ -831,9 +835,8 @@ namespace Jellyfin.LiveTv.Listings return bytes; } - private DateTime? LoadDailyLimitHitDate() + private static DateTime? LoadDailyLimitFile(string path) { - var path = DailyLimitFilePath; if (!File.Exists(path)) { return null; @@ -863,6 +866,37 @@ namespace Jellyfin.LiveTv.Listings return null; } + private bool IsDailyLimitActive(ref DateTime? hitDate, string filePath) + { + if (!hitDate.HasValue) + { + return false; + } + + if (hitDate.Value.Date < DateTime.UtcNow.Date) + { + hitDate = null; + TryDeleteFile(filePath); + return false; + } + + return true; + } + + private void SetDailyLimitHitDate(ref DateTime? hitDate, string filePath) + { + hitDate = DateTime.UtcNow; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to persist SD daily limit to {Path}", filePath); + } + } + private static void TryDeleteFile(string path) { try @@ -875,26 +909,6 @@ namespace Jellyfin.LiveTv.Listings } } - private void SetDailyLimitHitDate() - { - _dailyLimitHitDate = DateTime.UtcNow; - try - { - Directory.CreateDirectory(Path.GetDirectoryName(DailyLimitFilePath)!); - File.WriteAllText(DailyLimitFilePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); - } - catch (IOException ex) - { - _logger.LogWarning(ex, "Failed to persist SD daily limit hit date"); - } - } - - private void ClearDailyLimitHitDate() - { - _dailyLimitHitDate = null; - TryDeleteFile(DailyLimitFilePath); - } - public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) { if (validateLogin) From d63b2b2657763112fb1581a667c111e3930889f2 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 22 Feb 2026 12:37:14 +0100 Subject: [PATCH 12/99] Apply review suggestion --- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 7e1992baf2..47aa31c0f6 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -742,9 +742,13 @@ public class GuideManager : IGuideManager private async Task PreCacheImages(IReadOnlyList programs, DateTime maxCacheDate) { + var sdLimitActive = IsSdImageLimitActive(); + await Parallel.ForEachAsync( programs .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate) + .Where(p => !sdLimitActive || !p.ImageInfos.All( + img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase))) .DistinctBy(p => p.Id), _cacheParallelOptions, async (program, cancellationToken) => From 100d6bb38c5f7c24ea2a8d520add63d71948077f Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 23 Feb 2026 21:17:52 +0100 Subject: [PATCH 13/99] Gracefully handle empty listingId --- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 04589b3a8d..0b315d9a3d 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -171,7 +171,8 @@ namespace Jellyfin.LiveTv.Listings var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays); if (willBeCached && images is not null) { - var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId); + var imageIndex = images.FindIndex(i => + i.ProgramId is not null && schedule.ProgramId.StartsWith(i.ProgramId, StringComparison.Ordinal)); if (imageIndex > -1) { var programEntry = programDict[schedule.ProgramId]; @@ -938,7 +939,10 @@ namespace Jellyfin.LiveTv.Listings public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) { var listingsId = info.ListingsId; - ArgumentException.ThrowIfNullOrEmpty(listingsId); + if (string.IsNullOrEmpty(listingsId)) + { + return []; + } var token = await GetToken(info, cancellationToken).ConfigureAwait(false); From 41d20700083d5ca172335c2ac483843cfc4a4796 Mon Sep 17 00:00:00 2001 From: "ghis@am.com" Date: Tue, 24 Feb 2026 10:58:47 +1100 Subject: [PATCH 14/99] adding strm & http ignore to extract timestamp --- .../Probing/ProbeResultNormalizer.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index dbe5322897..e27195ec70 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -1679,6 +1679,13 @@ namespace MediaBrowser.MediaEncoding.Probing return; } + // Skip timestamp extration for remote resource (http, rtsp, etc.) + // as they cannot be opened with FileStream + if (video.Protocol != MediaProtocol.File) + { + return; + } + if (!string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase) && !string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase) && !string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase)) From b7da5c18605c2f953204645005dc9bd6729b6921 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 25 Feb 2026 14:51:53 +0100 Subject: [PATCH 15/99] Apply review suggestions --- Jellyfin.Api/Controllers/LiveTvController.cs | 4 +- .../LiveTv/ISchedulesDirectService.cs | 11 +- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 40 ++---- .../Listings/ListingsManager.cs | 18 ++- .../Listings/SchedulesDirect.cs | 118 ++++++++++++------ .../SchedulesDirectDtos/SdErrorCode.cs | 59 +++++++++ .../SchedulesDirectDtos/ShowImagesDto.cs | 12 ++ .../TunerHosts/TunerHostManager.cs | 19 ++- 8 files changed, 184 insertions(+), 97 deletions(-) create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 03c51a86ed..a366e9273b 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -1059,8 +1059,8 @@ public class LiveTvController : BaseJellyfinApiController [ProducesFile(MediaTypeNames.Application.Json)] public async Task GetSchedulesDirectCountries() { - var bytes = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false); - return File(bytes, MediaTypeNames.Application.Json); + var stream = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false); + return File(stream, MediaTypeNames.Application.Json); } /// diff --git a/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs b/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs index 496a2c4c55..a33b4422b2 100644 --- a/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs +++ b/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Threading; using System.Threading.Tasks; @@ -12,6 +13,12 @@ public interface ISchedulesDirectService /// Gets the available countries from the Schedules Direct API, using a file cache. /// /// The cancellation token. - /// The raw JSON response bytes. - Task GetAvailableCountries(CancellationToken cancellationToken); + /// A stream containing the raw JSON response. + Task GetAvailableCountries(CancellationToken cancellationToken); + + /// + /// Gets a value indicating whether the Schedules Direct daily image download limit is currently active. + /// + /// true if the image limit has been hit and has not yet reset; otherwise false. + bool IsImageDailyLimitActive(); } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 47aa31c0f6..a659cc020b 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -37,13 +37,9 @@ public class GuideManager : IGuideManager private readonly ILiveTvManager _liveTvManager; private readonly ITunerHostManager _tunerHostManager; private readonly IRecordingsManager _recordingsManager; + private readonly ISchedulesDirectService _schedulesDirectService; private readonly LiveTvDtoService _tvDtoService; - /// - /// UTC date when the SD image download limit was hit. Cleared after 00:00 UTC rollover. - /// - private DateTime? _sdImageLimitHitDate; - /// /// Amount of days images are pre-cached from external sources. /// @@ -60,6 +56,7 @@ public class GuideManager : IGuideManager /// The . /// The . /// The . + /// The . /// The . public GuideManager( ILogger logger, @@ -70,6 +67,7 @@ public class GuideManager : IGuideManager ILiveTvManager liveTvManager, ITunerHostManager tunerHostManager, IRecordingsManager recordingsManager, + ISchedulesDirectService schedulesDirectService, LiveTvDtoService tvDtoService) { _logger = logger; @@ -80,6 +78,7 @@ public class GuideManager : IGuideManager _liveTvManager = liveTvManager; _tunerHostManager = tunerHostManager; _recordingsManager = recordingsManager; + _schedulesDirectService = schedulesDirectService; _tvDtoService = tvDtoService; } @@ -726,23 +725,9 @@ public class GuideManager : IGuideManager return false; } - private bool IsSdImageLimitActive() - { - // The SD image counter resets daily at 00:00 UTC. - // If we recorded a limit hit on a previous UTC date, clear it. - var hitDate = _sdImageLimitHitDate; - if (hitDate.HasValue && hitDate.Value.Date < DateTime.UtcNow.Date) - { - _sdImageLimitHitDate = null; - return false; - } - - return hitDate.HasValue; - } - private async Task PreCacheImages(IReadOnlyList programs, DateTime maxCacheDate) { - var sdLimitActive = IsSdImageLimitActive(); + var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive(); await Parallel.ForEachAsync( programs @@ -768,7 +753,7 @@ public class GuideManager : IGuideManager // Skip SD downloads once the daily limit has been hit. if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase) - && IsSdImageLimitActive()) + && _schedulesDirectService.IsImageDailyLimitActive()) { continue; } @@ -785,18 +770,7 @@ public class GuideManager : IGuideManager } catch (Exception ex) { - if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase) - && !_sdImageLimitHitDate.HasValue) - { - _sdImageLimitHitDate = DateTime.UtcNow; - _logger.LogWarning( - "Schedules Direct image download failed for {Url}. Daily download limit may have been reached (resets at 00:00 UTC). Skipping remaining SD images until reset", - imageInfo.Path); - } - else if (!imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path); - } + _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path); } } }).ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index a37204cc57..c18ebe0ab0 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -341,17 +341,15 @@ public class ListingsManager : IListingsManager var cachePath = _config.CommonApplicationPaths?.CachePath; if (!string.IsNullOrEmpty(cachePath)) { - var xmltvCacheFile = Path.Combine(cachePath, "xmltv", providerId + ".xml"); - if (File.Exists(xmltvCacheFile)) + var safeId = Path.GetFileName(providerId); + var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml"); + try { - try - { - File.Delete(xmltvCacheFile); - } - catch (IOException ex) - { - _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", providerId); - } + File.Delete(xmltvCacheFile); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", providerId); } } } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 0b315d9a3d..7b97dcc8db 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -50,8 +50,8 @@ namespace Jellyfin.LiveTv.Listings private bool _disposed = false; private byte[] _countriesCache; - private DateTime? _imageLimitHitDate; - private DateTime? _metadataLimitHitDate; + private DateOnly? _imageLimitHitDate; + private DateOnly? _metadataLimitHitDate; public SchedulesDirect( ILogger logger, @@ -61,8 +61,8 @@ namespace Jellyfin.LiveTv.Listings _logger = logger; _httpClientFactory = httpClientFactory; _appPaths = appPaths; - _imageLimitHitDate = LoadDailyLimitFile(ImageLimitFilePath); - _metadataLimitHitDate = LoadDailyLimitFile(MetadataLimitFilePath); + _imageLimitHitDate = LoadDailyLimitDate(ImageLimitFilePath); + _metadataLimitHitDate = LoadDailyLimitDate(MetadataLimitFilePath); } /// @@ -93,7 +93,7 @@ namespace Jellyfin.LiveTv.Listings public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) { - if (IsDailyLimitActive(ref _metadataLimitHitDate, MetadataLimitFilePath)) + if (IsMetadataLimitActive()) { return []; } @@ -474,7 +474,7 @@ namespace Jellyfin.LiveTv.Listings IReadOnlyList programIds, CancellationToken cancellationToken) { - if (IsDailyLimitActive(ref _imageLimitHitDate, ImageLimitFilePath)) + if (IsImageDailyLimitActive()) { return []; } @@ -502,7 +502,20 @@ namespace Jellyfin.LiveTv.Listings var batchResult = await Request>(message, true, info, cancellationToken).ConfigureAwait(false); if (batchResult is not null) { - results.AddRange(batchResult); + foreach (var entry in batchResult) + { + if (entry.Code.HasValue) + { + _logger.LogWarning( + "Schedules Direct returned error for program {ProgramId}: code={Code}, message={Message}", + entry.ProgramId, + entry.Code, + entry.Message); + continue; + } + + results.Add(entry); + } } } catch (Exception ex) @@ -648,13 +661,15 @@ namespace Jellyfin.LiveTv.Listings var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); // Try to extract the Schedules Direct error code from the response body. - int? sdCode = null; + SdErrorCode? sdCode = null; try { using var doc = JsonDocument.Parse(responseBody); - if (doc.RootElement.TryGetProperty("code", out var codeProp) && codeProp.TryGetInt32(out var parsedCode)) + if (doc.RootElement.TryGetProperty("code", out var codeProp) + && codeProp.TryGetInt32(out var parsedCode) + && Enum.IsDefined((SdErrorCode)parsedCode)) { - sdCode = parsedCode; + sdCode = (SdErrorCode)parsedCode; } } catch (JsonException) @@ -666,44 +681,37 @@ namespace Jellyfin.LiveTv.Listings "Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}", message.RequestUri, (int)response.StatusCode, - sdCode?.ToString(CultureInfo.InvariantCulture) ?? "N/A", + sdCode?.ToString() ?? "N/A", responseBody); - if (sdCode is 4001 or 4003 or 4004 or 4005 or 4008) + if (sdCode is SdErrorCode.InvalidUser or SdErrorCode.InvalidHash or SdErrorCode.AccountLocked or SdErrorCode.AccountExpired or SdErrorCode.PasswordRequired) { // Permanent account errors — disable SD for this server lifetime. - // 4001=invalid user - // 4003=invalid hash - // 4004=account locked/disabled - // 4005=account expired - // 4008=password required _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart", sdCode); _tokens.Clear(); _accountError = true; } - else if (sdCode is 4009 or 4010) + else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.TemporaryLockout) { // Transient login errors — back off for 30 minutes, then allow retry. - // 4009=max login attempts - // 4010=temporary lockout _tokens.Clear(); _lastErrorResponse = DateTime.UtcNow; } - else if (sdCode is 5002) + else if (sdCode is SdErrorCode.MaxImageDownloads) { // Max image downloads — stop image requests until SD resets at 00:00 UTC. - SetDailyLimitHitDate(ref _imageLimitHitDate, ImageLimitFilePath); + SetImageLimitHit(); } - else if (sdCode is 5003) + else if (sdCode is SdErrorCode.MaxScheduleRequests) { // Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC. - SetDailyLimitHitDate(ref _metadataLimitHitDate, MetadataLimitFilePath); + SetMetadataLimitHit(); } else if (enableRetry && (int)response.StatusCode < 500 - && (sdCode == 4006 || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is null))) + && (sdCode == SdErrorCode.TokenExpired || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is null))) { - // 4006 = token expired — clear tokens and retry with a fresh token. + // Token expired — clear tokens and retry with a fresh token. // Also retry on 403 with no parseable SD code (legacy/unexpected auth failure). _tokens.Clear(); using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri); @@ -800,11 +808,11 @@ namespace Jellyfin.LiveTv.Listings } /// - public async Task GetAvailableCountries(CancellationToken cancellationToken) + public async Task GetAvailableCountries(CancellationToken cancellationToken) { if (_countriesCache is not null) { - return _countriesCache; + return new MemoryStream(_countriesCache, writable: false); } var cachePath = Path.Combine(_appPaths.CachePath, "sd-countries.json"); @@ -815,7 +823,7 @@ namespace Jellyfin.LiveTv.Listings try { _countriesCache = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false); - return _countriesCache; + return new MemoryStream(_countriesCache, writable: false); } catch (IOException) { @@ -833,10 +841,10 @@ namespace Jellyfin.LiveTv.Listings await File.WriteAllBytesAsync(cachePath, bytes, cancellationToken).ConfigureAwait(false); _countriesCache = bytes; - return bytes; + return new MemoryStream(bytes, writable: false); } - private static DateTime? LoadDailyLimitFile(string path) + private static DateOnly? LoadDailyLimitDate(string path) { if (!File.Exists(path)) { @@ -848,14 +856,15 @@ namespace Jellyfin.LiveTv.Listings var text = File.ReadAllText(path).Trim(); if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date)) { - if (date.Date < DateTime.UtcNow.Date) + var dateOnly = DateOnly.FromDateTime(date); + if (dateOnly < DateOnly.FromDateTime(DateTime.UtcNow)) { // Expired — clean up. File.Delete(path); return null; } - return date; + return dateOnly; } } catch (IOException) @@ -867,26 +876,55 @@ namespace Jellyfin.LiveTv.Listings return null; } - private bool IsDailyLimitActive(ref DateTime? hitDate, string filePath) + /// + public bool IsImageDailyLimitActive() { - if (!hitDate.HasValue) + if (!_imageLimitHitDate.HasValue) { return false; } - if (hitDate.Value.Date < DateTime.UtcNow.Date) + if (_imageLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow)) { - hitDate = null; - TryDeleteFile(filePath); + _imageLimitHitDate = null; + TryDeleteFile(ImageLimitFilePath); return false; } return true; } - private void SetDailyLimitHitDate(ref DateTime? hitDate, string filePath) + private bool IsMetadataLimitActive() + { + if (!_metadataLimitHitDate.HasValue) + { + return false; + } + + if (_metadataLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow)) + { + _metadataLimitHitDate = null; + TryDeleteFile(MetadataLimitFilePath); + return false; + } + + return true; + } + + private void SetImageLimitHit() + { + _imageLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow); + PersistDailyLimitFile(ImageLimitFilePath); + } + + private void SetMetadataLimitHit() + { + _metadataLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow); + PersistDailyLimitFile(MetadataLimitFilePath); + } + + private void PersistDailyLimitFile(string filePath) { - hitDate = DateTime.UtcNow; try { Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs new file mode 100644 index 0000000000..ec6c6c475b --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs @@ -0,0 +1,59 @@ +#pragma warning disable CA1008 // Enums should have zero value + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos; + +/// +/// Schedules Direct API error codes. +/// +public enum SdErrorCode +{ + /// + /// Invalid user. + /// + InvalidUser = 4001, + + /// + /// Invalid password hash. + /// + InvalidHash = 4003, + + /// + /// Account locked or disabled. + /// + AccountLocked = 4004, + + /// + /// Account expired. + /// + AccountExpired = 4005, + + /// + /// Token has expired. + /// + TokenExpired = 4006, + + /// + /// Password is required. + /// + PasswordRequired = 4008, + + /// + /// Maximum login attempts exceeded. + /// + MaxLoginAttempts = 4009, + + /// + /// Temporary lockout. + /// + TemporaryLockout = 4010, + + /// + /// Maximum image downloads reached for the day. + /// + MaxImageDownloads = 5002, + + /// + /// Maximum schedule/metadata requests reached for the day. + /// + MaxScheduleRequests = 5003 +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs index 8db75ef0b5..df96a47e26 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs @@ -15,6 +15,18 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos [JsonPropertyName("programID")] public string? ProgramId { get; set; } + /// + /// Gets or sets the SD error code, if the request for this program failed. + /// + [JsonPropertyName("code")] + public int? Code { get; set; } + + /// + /// Gets or sets the SD error message, if the request for this program failed. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + /// /// Gets or sets the list of data. /// diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs index 4043d7399e..7b2ebfe85e 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -110,17 +110,16 @@ public class TunerHostManager : ITunerHostManager // Clean up the disk cache file for this tuner if (!string.IsNullOrEmpty(id)) { - var channelCacheFile = Path.Combine(_config.CommonApplicationPaths.CachePath, id + "_channels"); - if (File.Exists(channelCacheFile)) + // Sanitize to prevent path traversal — tuner IDs are GUIDs but come from config. + var safeId = Path.GetFileName(id); + var channelCacheFile = Path.Combine(_config.CommonApplicationPaths.CachePath, safeId + "_channels"); + try { - try - { - File.Delete(channelCacheFile); - } - catch (IOException ex) - { - _logger.LogWarning(ex, "Error deleting channel cache file for tuner {TunerId}", id); - } + File.Delete(channelCacheFile); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Error deleting channel cache file for tuner {TunerId}", id); } } From e70eaf8bc1470436fdafb7fef6e23898b4a3423b Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 1 Mar 2026 13:10:32 +0000 Subject: [PATCH 16/99] Add startup mode to migrate or seed the database on cmd --- Jellyfin.Server/Configuration/StartupMode.cs | 24 ++++++++++++++++ .../Migrations/JellyfinMigrationService.cs | 6 ++-- Jellyfin.Server/Program.cs | 28 ++++++++++++------- Jellyfin.Server/StartupOptions.cs | 8 ++++++ .../JellyfinApplicationFactory.cs | 2 +- 5 files changed, 54 insertions(+), 14 deletions(-) create mode 100644 Jellyfin.Server/Configuration/StartupMode.cs diff --git a/Jellyfin.Server/Configuration/StartupMode.cs b/Jellyfin.Server/Configuration/StartupMode.cs new file mode 100644 index 0000000000..f4d63652d8 --- /dev/null +++ b/Jellyfin.Server/Configuration/StartupMode.cs @@ -0,0 +1,24 @@ +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.Server.Configuration; + +/// +/// Defines types for usage with the . +/// +public enum StartupMode +{ + /// + /// Default startup mode, runs the jellyfin server in normal operation. + /// + MediaServer = 0, + + /// + /// Attempts to Migrate the selected database only then shuts down. + /// + MigrateDatabase = 1, + + /// + /// Runs the Database seed function regardless of state. + /// + SeedDatabase = 2 +} diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 188d3c4a9a..9d70ef1208 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -90,7 +90,7 @@ internal class JellyfinMigrationService private HashSet Migrations { get; set; } - public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths) + public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths, StartupOptions startupOptions) { var logger = _startupLogger.With(_loggerFactory.CreateLogger()).BeginGroup($"Migration Startup"); logger.LogInformation("Initialise Migration service."); @@ -98,9 +98,9 @@ internal class JellyfinMigrationService var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath) ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)! : new ServerConfiguration(); - if (!serverConfig.IsStartupWizardCompleted) + if (!serverConfig.IsStartupWizardCompleted || startupOptions.StartupMode is Configuration.StartupMode.SeedDatabase) { - logger.LogInformation("System initialisation detected. Seed data."); + logger.LogInformation("System initialization detected. Seed data. Startup mode is: {StartupMode}", startupOptions.StartupMode ?? Configuration.StartupMode.MediaServer); var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray(); var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 93f71fdc69..e774c16510 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -137,7 +137,7 @@ namespace Jellyfin.Server StartupHelpers.PerformStaticInitialization(); - await ApplyStartupMigrationAsync(appPaths, startupConfig).ConfigureAwait(false); + await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false); do { @@ -214,13 +214,17 @@ namespace Jellyfin.Server { configurationCompleted = true; await _setupServer!.StopAsync().ConfigureAwait(false); - await _jellyfinHost.StartAsync().ConfigureAwait(false); - if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) { - var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + await _jellyfinHost.StartAsync().ConfigureAwait(false); - StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); + if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) + { + var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + + StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); + } } } catch (Exception) @@ -229,11 +233,14 @@ namespace Jellyfin.Server throw; } - await appHost.RunStartupTasksAsync().ConfigureAwait(false); + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) + { + await appHost.RunStartupTasksAsync().ConfigureAwait(false); + _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); - _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); + await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); + } - await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); _restartOnShutdown = appHost.ShouldRestart; _restoreFromBackup = appHost.RestoreBackupPath; } @@ -274,8 +281,9 @@ namespace Jellyfin.Server /// /// Application Paths. /// Startup Config. + /// The applications startup options. /// A task. - public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig) + public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig, StartupOptions startupOptions) { _migrationLogger = StartupLogger.Logger.BeginGroup($"Migration Service"); var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer()); @@ -293,7 +301,7 @@ namespace Jellyfin.Server PrepareDatabaseProvider(startupService); var jellyfinMigrationService = ActivatorUtilities.CreateInstance(startupService); - await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths).ConfigureAwait(false); + await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false); await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false); } diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs index 4890ccbb2e..4716bc1746 100644 --- a/Jellyfin.Server/StartupOptions.cs +++ b/Jellyfin.Server/StartupOptions.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using CommandLine; using Emby.Server.Implementations; +using Jellyfin.Server.Configuration; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; namespace Jellyfin.Server @@ -79,6 +80,13 @@ namespace Jellyfin.Server [Option("restore-archive", Required = false, HelpText = "Path to a Jellyfin backup archive to restore from")] public string? RestoreArchive { get; set; } + /// + /// Gets or sets the mode of operation the server should perform when started. + /// Defaults to: . + /// + [Option("mode", Required = false, HelpText = "Mode which selects what action the jellyfin server should perform when started.")] + public StartupMode? StartupMode { get; set; } + /// /// Gets the command line options as a dictionary that can be used in the .NET configuration system. /// diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 0952fb8b63..54f443de2d 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -111,7 +111,7 @@ namespace Jellyfin.Server.Integration.Tests var appHost = (TestAppHost)host.Services.GetRequiredService(); appHost.ServiceProvider = host.Services; var applicationPaths = appHost.ServiceProvider.GetRequiredService(); - Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService()).GetAwaiter().GetResult(); + Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService(), new()).GetAwaiter().GetResult(); Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisation).GetAwaiter().GetResult(); appHost.InitializeServices(Mock.Of()).GetAwaiter().GetResult(); Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult(); From 63c4fc297a918cf4407ba16dda5e98d34173dcad Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 1 Mar 2026 13:12:51 +0000 Subject: [PATCH 17/99] Update naming --- Jellyfin.Server/Configuration/StartupMode.cs | 6 +++--- Jellyfin.Server/Migrations/JellyfinMigrationService.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Jellyfin.Server/Configuration/StartupMode.cs b/Jellyfin.Server/Configuration/StartupMode.cs index f4d63652d8..e1d18f1dd6 100644 --- a/Jellyfin.Server/Configuration/StartupMode.cs +++ b/Jellyfin.Server/Configuration/StartupMode.cs @@ -13,12 +13,12 @@ public enum StartupMode MediaServer = 0, /// - /// Attempts to Migrate the selected database only then shuts down. + /// Attempts to Migrate the system only then shuts down. /// - MigrateDatabase = 1, + MigrateSystem = 1, /// /// Runs the Database seed function regardless of state. /// - SeedDatabase = 2 + SeedSystem = 2 } diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 9d70ef1208..d664b718bc 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -98,7 +98,7 @@ internal class JellyfinMigrationService var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath) ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)! : new ServerConfiguration(); - if (!serverConfig.IsStartupWizardCompleted || startupOptions.StartupMode is Configuration.StartupMode.SeedDatabase) + if (!serverConfig.IsStartupWizardCompleted || startupOptions.StartupMode is Configuration.StartupMode.SeedSystem) { logger.LogInformation("System initialization detected. Seed data. Startup mode is: {StartupMode}", startupOptions.StartupMode ?? Configuration.StartupMode.MediaServer); var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray(); From b22c8882d62405c5f7af0edbbfd6379e7d37bc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Teemu=20P=C3=B6yt=C3=A4niemi?= Date: Sun, 1 Mar 2026 16:28:55 +0200 Subject: [PATCH 18/99] Remove unnecessary ToList calls --- Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index 4505a377ce..56757c921e 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -198,13 +198,13 @@ public class TrickplayManager : ITrickplayManager // Cleanup old trickplay files if (Directory.Exists(trickplayDirectory)) { - var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList(); + var existingFolders = Directory.GetDirectories(trickplayDirectory); var trickplayInfos = await dbContext.TrickplayInfos .AsNoTracking() .Where(i => i.ItemId.Equals(video.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); - var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)).ToList(); + var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)); var foldersToRemove = existingFolders.Except(expectedFolders); foreach (var folder in foldersToRemove) { From 8271568677f886c865152d9818c7f2b012e943a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Teemu=20P=C3=B6yt=C3=A4niemi?= Date: Sun, 1 Mar 2026 16:35:24 +0200 Subject: [PATCH 19/99] Add poytiis to CONTRIBUTORS.md --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index cb7d3fbbc4..51b9c1ac7a 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -107,6 +107,7 @@ - [Phlogi](https://github.com/Phlogi) - [pjeanjean](https://github.com/pjeanjean) - [ploughpuff](https://github.com/ploughpuff) + - [poytiis](https://github.com/poytiis) - [pR0Ps](https://github.com/pR0Ps) - [PrplHaz4](https://github.com/PrplHaz4) - [RazeLighter777](https://github.com/RazeLighter777) From febfd7f94a291091cc1f54050f99c883116db694 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 1 Mar 2026 15:52:22 +0000 Subject: [PATCH 20/99] Stop server immediately on migration only mode --- Jellyfin.Server/Program.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index e774c16510..248f61d499 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -251,7 +251,11 @@ namespace Jellyfin.Server if (_setupServer!.IsAlive && !configurationCompleted) { _setupServer!.SoftStop(); - await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) + { + await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + } + await _setupServer!.StopAsync().ConfigureAwait(false); } } From 11e16df5967f2730db06f3a4789012a10ce0abad Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 2 Mar 2026 09:04:40 +0100 Subject: [PATCH 21/99] Fix Canadian ratings --- .../Localization/Ratings/ca.json | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.json b/Emby.Server.Implementations/Localization/Ratings/ca.json index fa43a8f2b7..a915dc8e31 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ca.json +++ b/Emby.Server.Implementations/Localization/Ratings/ca.json @@ -3,7 +3,7 @@ "supportsSubScores": true, "ratings": [ { - "ratingStrings": ["E", "G", "TV-Y", "TV-G"], + "ratingStrings": ["C", "E", "G", "TV-Y", "TV-G"], "ratingScore": { "score": 0, "subScore": 0 @@ -23,11 +23,18 @@ "subScore": 1 } }, + { + "ratingStrings": [], + "ratingScore": { + "score": 8, + "subScore": 0 + } + }, { "ratingStrings": ["PG", "TV-PG"], "ratingScore": { - "score": 9, - "subScore": 0 + "score": 8, + "subScore": 1 } }, { @@ -38,7 +45,7 @@ } }, { - "ratingStrings": ["TV-14"], + "ratingStrings": ["14+", "TV-14"], "ratingScore": { "score": 14, "subScore": 1 From 3d4e4c4572283a01d46fd14f588fa3fe39fb2cc0 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 2 Mar 2026 09:14:23 +0100 Subject: [PATCH 22/99] If we have a country code in the rating, treat as unrated if the country does not have the rating --- .../Localization/LocalizationManager.cs | 81 +++++++++++++------ .../Localization/LocalizationManagerTests.cs | 34 ++++++++ 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index bc80c2b405..f206b820fd 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -345,35 +345,70 @@ namespace Emby.Server.Implementations.Localization } } - // Try splitting by : to handle "Germany: FSK-18" - if (rating.Contains(':', StringComparison.OrdinalIgnoreCase)) + // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18" + if (TryGetRatingScoreBySeparator(rating, ':', out var result) + || TryGetRatingScoreBySeparator(rating, '-', out result)) { - var ratingLevelRightPart = rating.AsSpan().RightPart(':'); - if (ratingLevelRightPart.Length != 0) - { - return GetRatingScore(ratingLevelRightPart.ToString()); - } - } - - // Handle prefix country code to handle "DE-18" - if (rating.Contains('-', StringComparison.OrdinalIgnoreCase)) - { - var ratingSpan = rating.AsSpan(); - - // Extract culture from country prefix - var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString()); - - var ratingLevelRightPart = ratingSpan.RightPart('-'); - if (ratingLevelRightPart.Length != 0) - { - // Check rating system of culture - return GetRatingScore(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName); - } + return result; } return null; } + private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result) + { + result = null; + + if (rating.IndexOf(separator, StringComparison.Ordinal) < 0) + { + return false; + } + + var ratingSpan = rating.AsSpan(); + var countryPart = ratingSpan.LeftPart(separator).Trim().ToString(); + var ratingPart = ratingSpan.RightPart(separator).Trim().ToString(); + if (ratingPart.Length == 0) + { + return false; + } + + string? resolvedCountryCode = null; + + if (_allParentalRatings.ContainsKey(countryPart)) + { + resolvedCountryCode = countryPart; + } + else + { + var culture = FindLanguageInfo(countryPart); + if (culture is not null) + { + resolvedCountryCode = culture.TwoLetterISOLanguageName; + } + } + + if (resolvedCountryCode is not null + && _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings)) + { + if (countryRatings.TryGetValue(ratingPart, out result)) + { + return true; + } + + _logger.LogWarning( + "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", + rating, + resolvedCountryCode); + + return true; + } + + // Country not identified or no rating data available, try recursive lookup + result = GetRatingScore(ratingPart, resolvedCountryCode); + + return true; + } + /// public string GetLocalizedString(string phrase) { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index e60522bf78..3e2df15ded 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -222,6 +222,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Equal(expectedSubScore, score.SubScore); } + [Theory] + [InlineData("US:INVALID", "US")] // Colon separator, known country code, unknown rating + [InlineData("us:INVALID", "US")] // Colon separator, lowercase country code + [InlineData("DE-INVALID", "US")] // Hyphen separator, known language prefix, unknown rating + [InlineData("ca:INVALID", "US")] // Colon separator, known country code (Canada) + public async Task GetRatingScore_UnknownRatingWithKnownCountry_ReturnsNull(string rating, string countryCode) + { + var localizationManager = Setup(new ServerConfiguration + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + + Assert.Null(localizationManager.GetRatingScore(rating)); + } + + [Theory] + [InlineData("us:R", "DE", 17, 0)] // Colon separator, explicit US country, valid US rating + [InlineData("US:PG-13", "DE", 13, 0)] // Colon separator, explicit US country, valid US rating + [InlineData("ca:R", "US", 18, 1)] // Colon separator, Canada country code, valid CA rating + public async Task GetRatingScore_ValidRatingWithCountrySeparator_ReturnsScore(string rating, string countryCode, int expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(rating); + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + [Theory] [InlineData("Default", "Default")] [InlineData("HeaderLiveTV", "Live TV")] From d5fb6f99ef416cd81d547459e256e91debb8f1ec Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 2 Mar 2026 09:14:34 +0100 Subject: [PATCH 23/99] Refresh rating levels --- Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs index 2a6db01cf3..ed92c34aa3 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; using Jellyfin.Database.Implementations; using Jellyfin.Server.ServerSetupApp; @@ -12,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// Migrate rating levels. /// #pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2025-04-20T22:00:00", nameof(MigrateRatingLevels))] +[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))] [JellyfinMigrationBackup(JellyfinDb = true)] #pragma warning restore CS0618 // Type or member is obsolete internal class MigrateRatingLevels : IDatabaseMigrationRoutine From e065015d6daf39b9f4a95c7783f3af9cde56d69e Mon Sep 17 00:00:00 2001 From: Francisco Ernesto Planas Pestana Date: Wed, 25 Mar 2026 14:07:53 +0000 Subject: [PATCH 24/99] Fix #16308: Community ratings not updating after changing .nfo file. When "replace all metadata" was issued on a film, with the Web metadata scrapers and "save to local metadata" disabled, after changing the .nfo file, 'Community rating' was not updated in the server, remaining the cached value. Fixed by changing the flag on the 'MergeData' call (line 809 - MetadataService.cs) to use the option defined by the user and not the erroneous absolute 'false' value. Also, in the .nfo parser an option for 'communityrating' was added along with value conformity verifiers. Validation tests were added. --- .../Manager/MetadataService.cs | 2 +- .../Parsers/BaseNfoParser.cs | 10 +++++ .../Parsers/MovieNfoParserTests.cs | 43 +++++++++++++++++++ .../Test Data/CommunityRating.nfo | 5 +++ .../Test Data/CommunityRating_Comma.nfo | 5 +++ .../Test Data/CommunityRating_OutOfRange.nfo | 5 +++ 6 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo create mode 100644 tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo create mode 100644 tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index e9cb46eab5..d48b525a40 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -806,7 +806,7 @@ namespace MediaBrowser.Providers.Manager refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; } - MergeData(localItem, temp, [], false, true); + MergeData(localItem, temp, [], options.ReplaceAllMetadata, true); refreshResult.UpdateType |= ItemUpdateType.MetadataImport; break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 3f83f1d829..d3f0bfb5d4 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -542,6 +542,16 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; case "ratings": FetchFromRatingsNode(reader, item); + break; + // For NFO files that have a separate community rating tag instead of using the ratings node with a name, or standard rating tag + case "communityrating": + var communityRatingText = reader.ReadElementContentAsString().Replace(',', '.'); + if (float.TryParse(communityRatingText, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var communityRatingValue) + && communityRatingValue >= 0 && communityRatingValue <= 10) + { + item.CommunityRating = communityRatingValue; + } + break; case "aired": case "formed": diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index 1e8652f4b9..4142831c31 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -294,5 +294,48 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers // Verify that the lowercase "tmdbcol" is NOT in the provider IDs Assert.False(item.ProviderIds.ContainsKey("tmdbcol")); } + + [Fact] + public void Parse_CommunityRating_ValidRating_Success() + { + var result = new MetadataResult