aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-02-25 14:51:53 +0100
committerShadowghost <Ghost_of_Stone@web.de>2026-02-25 14:51:53 +0100
commitb7da5c18605c2f953204645005dc9bd6729b6921 (patch)
tree7787c9835431be3d329af35cc42305aa2b36b9c5 /src
parent100d6bb38c5f7c24ea2a8d520add63d71948077f (diff)
Apply review suggestions
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs40
-rw-r--r--src/Jellyfin.LiveTv/Listings/ListingsManager.cs18
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs118
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs59
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs12
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs19
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);
}
}