diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-02-25 14:51:53 +0100 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-02-25 14:51:53 +0100 |
| commit | b7da5c18605c2f953204645005dc9bd6729b6921 (patch) | |
| tree | 7787c9835431be3d329af35cc42305aa2b36b9c5 /src | |
| parent | 100d6bb38c5f7c24ea2a8d520add63d71948077f (diff) | |
Apply review suggestions
Diffstat (limited to 'src')
6 files changed, 173 insertions, 93 deletions
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,14 +37,10 @@ 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> public const int MaxCacheDays = 2; @@ -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); 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<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 (!_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; + +/// <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 +} 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 @@ -16,6 +16,18 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos 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> [JsonPropertyName("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); } } |
