From e49d71707c5f9f46fca373922a1ac1893cfc6ad5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 11 Feb 2026 09:44:37 +0100 Subject: Fix EPG issues --- MediaBrowser.Controller/LiveTv/ITunerHostManager.cs | 6 ++++++ MediaBrowser.Controller/LiveTv/LiveTvChannel.cs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) (limited to 'MediaBrowser.Controller') 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); -- cgit v1.2.3 From 27396bffc6d2cc0595ffba4dd400a9e5bbfafc0f Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 22 Feb 2026 11:14:15 +0100 Subject: 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 +++++++-- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 137 ++++++++++++++++++++- 5 files changed, 211 insertions(+), 49 deletions(-) create mode 100644 MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs (limited to 'MediaBrowser.Controller') 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) + { + 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) { - _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path); - try + 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) -- cgit v1.2.3 From b7da5c18605c2f953204645005dc9bd6729b6921 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 25 Feb 2026 14:51:53 +0100 Subject: Apply review suggestions --- Jellyfin.Api/Controllers/LiveTvController.cs | 4 +- .../LiveTv/ISchedulesDirectService.cs | 11 +- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 40 ++----- src/Jellyfin.LiveTv/Listings/ListingsManager.cs | 18 ++-- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 118 ++++++++++++++------- .../Listings/SchedulesDirectDtos/SdErrorCode.cs | 59 +++++++++++ .../Listings/SchedulesDirectDtos/ShowImagesDto.cs | 12 +++ src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs | 19 ++-- 8 files changed, 184 insertions(+), 97 deletions(-) create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs (limited to 'MediaBrowser.Controller') 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 + { + File.Delete(xmltvCacheFile); + } + catch (IOException ex) { - try - { - File.Delete(xmltvCacheFile); - } - 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}", 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 (!_imageLimitHitDate.HasValue) + { + return false; + } + + if (_imageLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow)) + { + _imageLimitHitDate = null; + TryDeleteFile(ImageLimitFilePath); + return false; + } + + return true; + } + + private bool IsMetadataLimitActive() { - if (!hitDate.HasValue) + if (!_metadataLimitHitDate.HasValue) { return false; } - if (hitDate.Value.Date < DateTime.UtcNow.Date) + if (_metadataLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow)) { - hitDate = null; - TryDeleteFile(filePath); + _metadataLimitHitDate = null; + TryDeleteFile(MetadataLimitFilePath); return false; } return true; } - private void SetDailyLimitHitDate(ref DateTime? hitDate, string filePath) + 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); } } -- cgit v1.2.3 From 60e01e1f22fa6fc3505469abd96d85d64b05fac1 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 11 Apr 2026 18:00:41 +0200 Subject: Apply review suggestions --- .../LiveTv/ISchedulesDirectService.cs | 7 ++++++ src/Jellyfin.LiveTv/Guide/GuideManager.cs | 8 +++++++ src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 26 +++++++++++++++++----- 3 files changed, 36 insertions(+), 5 deletions(-) (limited to 'MediaBrowser.Controller') 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() { -- cgit v1.2.3