Apply review suggestions

This commit is contained in:
Shadowghost
2026-02-25 14:51:53 +01:00
parent 100d6bb38c
commit b7da5c1860
8 changed files with 184 additions and 97 deletions

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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)!);

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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);
}
}