mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-12 19:56:30 +01:00
Apply review suggestions
This commit is contained in:
@@ -1059,8 +1059,8 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||
public async Task<ActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The raw JSON response bytes.</returns>
|
||||
Task<byte[]> GetAvailableCountries(CancellationToken cancellationToken);
|
||||
/// <returns>A stream containing the raw JSON response.</returns>
|
||||
Task<Stream> GetAvailableCountries(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the Schedules Direct daily image download limit is currently active.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> if the image limit has been hit and has not yet reset; otherwise <c>false</c>.</returns>
|
||||
bool IsImageDailyLimitActive();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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>
|
||||
@@ -60,6 +56,7 @@ public class GuideManager : IGuideManager
|
||||
/// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
|
||||
/// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
|
||||
/// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
|
||||
/// <param name="schedulesDirectService">The <see cref="ISchedulesDirectService"/>.</param>
|
||||
/// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
|
||||
public GuideManager(
|
||||
ILogger<GuideManager> 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<BaseItem> 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SchedulesDirect> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -93,7 +93,7 @@ namespace Jellyfin.LiveTv.Listings
|
||||
|
||||
public async Task<IEnumerable<ProgramInfo>> 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<string> programIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsDailyLimitActive(ref _imageLimitHitDate, ImageLimitFilePath))
|
||||
if (IsImageDailyLimitActive())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
@@ -502,7 +502,20 @@ namespace Jellyfin.LiveTv.Listings
|
||||
var batchResult = await Request<IReadOnlyList<ShowImagesDto>>(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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> GetAvailableCountries(CancellationToken cancellationToken)
|
||||
public async Task<Stream> 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)
|
||||
/// <inheritdoc />
|
||||
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)!);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma warning disable CA1008 // Enums should have zero value
|
||||
|
||||
namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
|
||||
|
||||
/// <summary>
|
||||
/// Schedules Direct API error codes.
|
||||
/// </summary>
|
||||
public enum SdErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Invalid user.
|
||||
/// </summary>
|
||||
InvalidUser = 4001,
|
||||
|
||||
/// <summary>
|
||||
/// Invalid password hash.
|
||||
/// </summary>
|
||||
InvalidHash = 4003,
|
||||
|
||||
/// <summary>
|
||||
/// Account locked or disabled.
|
||||
/// </summary>
|
||||
AccountLocked = 4004,
|
||||
|
||||
/// <summary>
|
||||
/// Account expired.
|
||||
/// </summary>
|
||||
AccountExpired = 4005,
|
||||
|
||||
/// <summary>
|
||||
/// Token has expired.
|
||||
/// </summary>
|
||||
TokenExpired = 4006,
|
||||
|
||||
/// <summary>
|
||||
/// Password is required.
|
||||
/// </summary>
|
||||
PasswordRequired = 4008,
|
||||
|
||||
/// <summary>
|
||||
/// Maximum login attempts exceeded.
|
||||
/// </summary>
|
||||
MaxLoginAttempts = 4009,
|
||||
|
||||
/// <summary>
|
||||
/// Temporary lockout.
|
||||
/// </summary>
|
||||
TemporaryLockout = 4010,
|
||||
|
||||
/// <summary>
|
||||
/// Maximum image downloads reached for the day.
|
||||
/// </summary>
|
||||
MaxImageDownloads = 5002,
|
||||
|
||||
/// <summary>
|
||||
/// Maximum schedule/metadata requests reached for the day.
|
||||
/// </summary>
|
||||
MaxScheduleRequests = 5003
|
||||
}
|
||||
@@ -15,6 +15,18 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos
|
||||
[JsonPropertyName("programID")]
|
||||
public string? ProgramId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SD error code, if the request for this program failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public int? Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SD error message, if the request for this program failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of data.
|
||||
/// </summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user