From e49d71707c5f9f46fca373922a1ac1893cfc6ad5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 11 Feb 2026 09:44:37 +0100 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 b7da5c18605c2f953204645005dc9bd6729b6921 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 25 Feb 2026 14:51:53 +0100 Subject: [PATCH 11/13] 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 60e01e1f22fa6fc3505469abd96d85d64b05fac1 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 11 Apr 2026 18:00:41 +0200 Subject: [PATCH 12/13] Apply review suggestions --- .../LiveTv/ISchedulesDirectService.cs | 7 +++++ src/Jellyfin.LiveTv/Guide/GuideManager.cs | 8 ++++++ .../Listings/SchedulesDirect.cs | 26 +++++++++++++++---- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs b/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs index a33b4422b2..6953650952 100644 --- a/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs +++ b/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs @@ -21,4 +21,11 @@ public interface ISchedulesDirectService /// /// true if the image limit has been hit and has not yet reset; otherwise false. bool IsImageDailyLimitActive(); + + /// + /// Gets a value indicating whether the Schedules Direct service is available. + /// Returns false if a permanent account error has occurred or a transient backoff is active. + /// + /// true if the service can accept requests; otherwise false. + bool IsServiceAvailable(); } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index a659cc020b..556516674b 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -738,6 +738,14 @@ public class GuideManager : IGuideManager _cacheParallelOptions, async (program, cancellationToken) => { + // Re-check: limit may have been set by a parallel task since the LINQ filter ran. + if (_schedulesDirectService.IsImageDailyLimitActive() + && program.ImageInfos.All( + img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase))) + { + return; + } + for (var i = 0; i < program.ImageInfos.Length; i++) { if (cancellationToken.IsCancellationRequested) diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 7b97dcc8db..3aa0f0408b 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -45,8 +45,8 @@ namespace Jellyfin.LiveTv.Listings private readonly ConcurrentDictionary _tokens = new(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private DateTime _lastErrorResponse; - private bool _accountError; + private long _lastErrorResponseTicks; + private volatile bool _accountError; private bool _disposed = false; private byte[] _countriesCache; @@ -594,7 +594,7 @@ namespace Jellyfin.LiveTv.Listings } // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout) - if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 30) + if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30) { return null; } @@ -635,7 +635,7 @@ namespace Jellyfin.LiveTv.Listings && (int)ex.StatusCode.Value < 500) { _tokens.Clear(); - _lastErrorResponse = DateTime.UtcNow; + Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks); } throw; @@ -695,7 +695,7 @@ namespace Jellyfin.LiveTv.Listings { // Transient login errors — back off for 30 minutes, then allow retry. _tokens.Clear(); - _lastErrorResponse = DateTime.UtcNow; + Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks); } else if (sdCode is SdErrorCode.MaxImageDownloads) { @@ -876,6 +876,22 @@ namespace Jellyfin.LiveTv.Listings return null; } + /// + public bool IsServiceAvailable() + { + if (_accountError) + { + return false; + } + + if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30) + { + return false; + } + + return true; + } + /// public bool IsImageDailyLimitActive() { From 6be96100c72a77b5c1db5921ec731ee002b7c48d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 4 May 2026 21:33:10 +0200 Subject: [PATCH 13/13] Fix review and CodeQL comments --- src/Jellyfin.LiveTv/Listings/ListingsManager.cs | 11 +++++++++-- src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs | 11 ++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index c18ebe0ab0..58683deb30 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -337,11 +337,18 @@ public class ListingsManager : IListingsManager // Clear in-memory EPG channel cache for this provider _epgChannels.TryRemove(providerId, out _); + // Provider IDs are generated as Guid.NewGuid().ToString("N") + // reject anything else so we never use untrusted input in a path or log entry. + if (!Guid.TryParseExact(providerId, "N", out var providerGuid)) + { + return; + } + // Delete the cached XMLTV file so a fresh copy is downloaded var cachePath = _config.CommonApplicationPaths?.CachePath; if (!string.IsNullOrEmpty(cachePath)) { - var safeId = Path.GetFileName(providerId); + var safeId = providerGuid.ToString("N", CultureInfo.InvariantCulture); var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml"); try { @@ -349,7 +356,7 @@ public class ListingsManager : IListingsManager } catch (IOException ex) { - _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", providerId); + _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId); } } } diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs index 7b2ebfe85e..cfd763b6fd 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -107,11 +107,12 @@ public class TunerHostManager : ITunerHostManager 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)) + // Clean up the disk cache file for this tuner. + // Tuner IDs are generated as Guid.NewGuid().ToString("N") + // reject anything else so we never use untrusted input in a path or log entry + if (Guid.TryParseExact(id, "N", out var tunerGuid)) { - // Sanitize to prevent path traversal — tuner IDs are GUIDs but come from config. - var safeId = Path.GetFileName(id); + var safeId = tunerGuid.ToString("N", CultureInfo.InvariantCulture); var channelCacheFile = Path.Combine(_config.CommonApplicationPaths.CachePath, safeId + "_channels"); try { @@ -119,7 +120,7 @@ public class TunerHostManager : ITunerHostManager } catch (IOException ex) { - _logger.LogWarning(ex, "Error deleting channel cache file for tuner {TunerId}", id); + _logger.LogWarning(ex, "Error deleting channel cache file for tuner {TunerId}", safeId); } }