diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-02-22 11:14:15 +0100 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-02-22 11:14:15 +0100 |
| commit | 27396bffc6d2cc0595ffba4dd400a9e5bbfafc0f (patch) | |
| tree | 662a7189e9c96d80df9a1d51babe2b23207c2946 /src | |
| parent | d156e04c9a2b16d38aede38f0de773a4d128e48f (diff) | |
Handle 5002, 5003 and add caches
Diffstat (limited to 'src')
| -rw-r--r-- | src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs | 4 | ||||
| -rw-r--r-- | src/Jellyfin.LiveTv/Guide/GuideManager.cs | 59 | ||||
| -rw-r--r-- | src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 137 |
3 files changed, 187 insertions, 13 deletions
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<ILiveTvService, DefaultLiveTvService>(); services.AddSingleton<ITunerHost, HdHomerunHost>(); services.AddSingleton<ITunerHost, M3UTunerHost>(); - services.AddSingleton<IListingsProvider, SchedulesDirect>(); + services.AddSingleton<SchedulesDirect>(); + services.AddSingleton<IListingsProvider>(s => s.GetRequiredService<SchedulesDirect>()); + services.AddSingleton<ISchedulesDirectService>(s => s.GetRequiredService<SchedulesDirect>()); services.AddSingleton<IListingsProvider, XmlTvListingsProvider>(); } } 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 @@ -40,6 +40,11 @@ public class GuideManager : IGuideManager private readonly LiveTvDtoService _tvDtoService; /// <summary> + /// UTC date when the SD image download limit was hit. Cleared after 00:00 UTC rollover. + /// </summary> + private DateTime? _sdImageLimitHitDate; + + /// <summary> /// Amount of days images are pre-cached from external sources. /// </summary> public const int MaxCacheDays = 2; @@ -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<BaseItem> 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<SchedulesDirect> _logger; private readonly IHttpClientFactory _httpClientFactory; + private readonly IApplicationPaths _appPaths; private readonly AsyncNonKeyedLocker _tokenLock = new(1); private readonly ConcurrentDictionary<string, NameValuePair> _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<SchedulesDirect> logger, - IHttpClientFactory httpClientFactory) + IHttpClientFactory httpClientFactory, + IApplicationPaths appPaths) { _logger = logger; _httpClientFactory = httpClientFactory; + _appPaths = appPaths; + _dailyLimitHitDate = LoadDailyLimitHitDate(); } /// <inheritdoc /> public string Name => "Schedules Direct"; + private string DailyLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-daily-limit.txt"); + /// <inheritdoc /> 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 } } + /// <inheritdoc /> + public async Task<byte[]> 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) |
