aboutsummaryrefslogtreecommitdiff
path: root/src/Jellyfin.LiveTv
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-02-22 11:14:15 +0100
committerShadowghost <Ghost_of_Stone@web.de>2026-02-22 11:14:15 +0100
commit27396bffc6d2cc0595ffba4dd400a9e5bbfafc0f (patch)
tree662a7189e9c96d80df9a1d51babe2b23207c2946 /src/Jellyfin.LiveTv
parentd156e04c9a2b16d38aede38f0de773a4d128e48f (diff)
Handle 5002, 5003 and add caches
Diffstat (limited to 'src/Jellyfin.LiveTv')
-rw-r--r--src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs4
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs59
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs137
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)