aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorNiels van Velzen <nielsvanvelzen@users.noreply.github.com>2026-05-05 15:53:19 +0200
committerGitHub <noreply@github.com>2026-05-05 15:53:19 +0200
commit4178e0ebaf2ff7162f474e17e27cd5bbbfafd548 (patch)
tree819a9ec8361334847d8601448273cd71f2aae20a /src
parent064fd8c5c0946ccecc686606528faa8f3b47dc96 (diff)
parent6be96100c72a77b5c1db5921ec731ee002b7c48d (diff)
Merge pull request #16220 from Shadowghost/epg-fixesHEADmaster
Fix EPG issues
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.LiveTv/DefaultLiveTvService.cs5
-rw-r--r--src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs4
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs55
-rw-r--r--src/Jellyfin.LiveTv/Listings/ListingsManager.cs39
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs360
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs42
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs59
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs13
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs49
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs28
10 files changed, 577 insertions, 77 deletions
diff --git a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
index d8f873abe6..d477bc3713 100644
--- a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
+++ b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
@@ -774,7 +774,10 @@ namespace Jellyfin.LiveTv
}
}
- SearchForDuplicateShowIds(enabledTimersForSeries);
+ if (seriesTimer.SkipEpisodesInLibrary)
+ {
+ SearchForDuplicateShowIds(enabledTimersForSeries);
+ }
if (deleteInvalidTimers)
{
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..556516674b 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -37,6 +37,7 @@ 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>
@@ -55,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,
@@ -65,6 +67,7 @@ public class GuideManager : IGuideManager
ILiveTvManager liveTvManager,
ITunerHostManager tunerHostManager,
IRecordingsManager recordingsManager,
+ ISchedulesDirectService schedulesDirectService,
LiveTvDtoService tvDtoService)
{
_logger = logger;
@@ -75,6 +78,7 @@ public class GuideManager : IGuideManager
_liveTvManager = liveTvManager;
_tunerHostManager = tunerHostManager;
_recordingsManager = recordingsManager;
+ _schedulesDirectService = schedulesDirectService;
_tvDtoService = tvDtoService;
}
@@ -723,13 +727,25 @@ public class GuideManager : IGuideManager
private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
{
+ var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive();
+
await Parallel.ForEachAsync(
programs
.Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
+ .Where(p => !sdLimitActive || !p.ImageInfos.All(
+ img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
.DistinctBy(p => p.Id),
_cacheParallelOptions,
async (program, cancellationToken) =>
{
+ // Re-check: limit may have been set by a parallel task since the LINQ filter ran.
+ if (_schedulesDirectService.IsImageDailyLimitActive()
+ && program.ImageInfos.All(
+ img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
for (var i = 0; i < program.ImageInfos.Length; i++)
{
if (cancellationToken.IsCancellationRequested)
@@ -738,22 +754,31 @@ public class GuideManager : IGuideManager
}
var imageInfo = program.ImageInfos[i];
- if (!imageInfo.IsLocalFile)
+ if (imageInfo.IsLocalFile)
{
- _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.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
- }
+ continue;
+ }
+
+ // Skip SD downloads once the daily limit has been hit.
+ if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)
+ && _schedulesDirectService.IsImageDailyLimitActive())
+ {
+ 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.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 39c2bd375b..58683deb30 100644
--- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
+++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
+using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -74,6 +75,9 @@ public class ListingsManager : IListingsManager
}
_config.SaveConfiguration("livetv", config);
+
+ InvalidateListingsProviderCache(info.Id);
+
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
return info;
@@ -87,6 +91,12 @@ public class ListingsManager : IListingsManager
config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
_config.SaveConfiguration("livetv", config);
+
+ if (!string.IsNullOrEmpty(id))
+ {
+ InvalidateListingsProviderCache(id);
+ }
+
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
}
@@ -322,6 +332,35 @@ public class ListingsManager : IListingsManager
return channelId;
}
+ private void InvalidateListingsProviderCache(string providerId)
+ {
+ // Clear in-memory EPG channel cache for this provider
+ _epgChannels.TryRemove(providerId, out _);
+
+ // Provider IDs are generated as Guid.NewGuid().ToString("N")
+ // reject anything else so we never use untrusted input in a path or log entry.
+ if (!Guid.TryParseExact(providerId, "N", out var providerGuid))
+ {
+ return;
+ }
+
+ // Delete the cached XMLTV file so a fresh copy is downloaded
+ var cachePath = _config.CommonApplicationPaths?.CachePath;
+ if (!string.IsNullOrEmpty(cachePath))
+ {
+ var safeId = providerGuid.ToString("N", CultureInfo.InvariantCulture);
+ var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml");
+ try
+ {
+ File.Delete(xmltvCacheFile);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId);
+ }
+ }
+ }
+
private async Task<EpgChannelData> GetEpgChannels(
IListingsProvider provider,
ListingsProviderInfo info,
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index d6f15906ef..3aa0f0408b 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,30 +33,45 @@ 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();
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
- private DateTime _lastErrorResponse;
+ private long _lastErrorResponseTicks;
+ private volatile bool _accountError;
private bool _disposed = false;
+ private byte[] _countriesCache;
+ private DateOnly? _imageLimitHitDate;
+ private DateOnly? _metadataLimitHitDate;
+
public SchedulesDirect(
ILogger<SchedulesDirect> logger,
- IHttpClientFactory httpClientFactory)
+ IHttpClientFactory httpClientFactory,
+ IApplicationPaths appPaths)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
+ _appPaths = appPaths;
+ _imageLimitHitDate = LoadDailyLimitDate(ImageLimitFilePath);
+ _metadataLimitHitDate = LoadDailyLimitDate(MetadataLimitFilePath);
}
/// <inheritdoc />
public string Name => "Schedules Direct";
+ private string ImageLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-image-limit.txt");
+
+ private string MetadataLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-metadata-limit.txt");
+
/// <inheritdoc />
public string Type => nameof(SchedulesDirect);
@@ -76,6 +93,11 @@ namespace Jellyfin.LiveTv.Listings
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
{
+ if (IsMetadataLimitActive())
+ {
+ return [];
+ }
+
ArgumentException.ThrowIfNullOrEmpty(channelId);
// Normalize incoming input
@@ -149,7 +171,8 @@ namespace Jellyfin.LiveTv.Listings
var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays);
if (willBeCached && images is not null)
{
- var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]);
+ var imageIndex = images.FindIndex(i =>
+ i.ProgramId is not null && schedule.ProgramId.StartsWith(i.ProgramId, StringComparison.Ordinal));
if (imageIndex > -1)
{
var programEntry = programDict[schedule.ProgramId];
@@ -451,39 +474,57 @@ namespace Jellyfin.LiveTv.Listings
IReadOnlyList<string> programIds,
CancellationToken cancellationToken)
{
+ if (IsImageDailyLimitActive())
+ {
+ return [];
+ }
+
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
- if (programIds.Count == 0)
+ if (string.IsNullOrEmpty(token) || programIds.Count == 0)
{
return [];
}
- StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
- foreach (var i in programIds)
+ // SD API accepts max 500 program IDs per request
+ const int BatchSize = 500;
+ var results = new List<ShowImagesDto>();
+ for (int i = 0; i < programIds.Count; i += BatchSize)
{
- str.Append('"')
- .Append(i[..10])
- .Append("\",");
- }
+ var batch = programIds.Skip(i).Take(BatchSize);
- // Remove last ,
- str.Length--;
- str.Append(']');
+ using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
+ message.Headers.TryAddWithoutValidation("token", token);
+ message.Content = JsonContent.Create(batch, options: _jsonOptions);
- using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs");
- message.Headers.TryAddWithoutValidation("token", token);
- message.Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json);
+ try
+ {
+ var batchResult = await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false);
+ if (batchResult is not null)
+ {
+ 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;
+ }
- try
- {
- return await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false);
+ results.Add(entry);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting image info from schedules direct");
+ }
}
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error getting image info from schedules direct");
- return [];
- }
+ return results;
}
public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken)
@@ -546,8 +587,14 @@ namespace Jellyfin.LiveTv.Listings
return null;
}
- // Avoid hammering SD
- if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1)
+ // Permanent account error — SD is disabled for this server lifetime.
+ if (_accountError)
+ {
+ return null;
+ }
+
+ // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout)
+ if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30)
{
return null;
}
@@ -579,10 +626,16 @@ namespace Jellyfin.LiveTv.Listings
}
catch (HttpRequestException ex)
{
- if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
+ // For 4xx errors not already handled by Request<T>'s SD code logic
+ // (e.g. unparseable response from the /token endpoint), apply a
+ // temporary backoff to avoid hammering SD.
+ if (!_accountError
+ && ex.StatusCode.HasValue
+ && (int)ex.StatusCode.Value >= 400
+ && (int)ex.StatusCode.Value < 500)
{
_tokens.Clear();
- _lastErrorResponse = DateTime.UtcNow;
+ Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
}
throw;
@@ -605,27 +658,75 @@ namespace Jellyfin.LiveTv.Listings
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false);
}
- if (!enableRetry || (int)response.StatusCode >= 500)
- {
- _logger.LogError(
- "Request to {Url} failed with response {Response}",
- message.RequestUri,
- await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
+ var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
- throw new HttpRequestException(
- string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
- null,
- response.StatusCode);
+ // Try to extract the Schedules Direct error code from the response body.
+ SdErrorCode? sdCode = null;
+ try
+ {
+ using var doc = JsonDocument.Parse(responseBody);
+ if (doc.RootElement.TryGetProperty("code", out var codeProp)
+ && codeProp.TryGetInt32(out var parsedCode)
+ && Enum.IsDefined((SdErrorCode)parsedCode))
+ {
+ sdCode = (SdErrorCode)parsedCode;
+ }
+ }
+ catch (JsonException)
+ {
+ // Response body is not valid JSON; sdCode stays null.
}
- _tokens.Clear();
- using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
- retryMessage.Content = message.Content;
- retryMessage.Headers.TryAddWithoutValidation(
- "token",
- await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
+ _logger.LogError(
+ "Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}",
+ message.RequestUri,
+ (int)response.StatusCode,
+ sdCode?.ToString() ?? "N/A",
+ responseBody);
- return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
+ 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.
+ _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart", sdCode);
+ _tokens.Clear();
+ _accountError = true;
+ }
+ else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.TemporaryLockout)
+ {
+ // Transient login errors — back off for 30 minutes, then allow retry.
+ _tokens.Clear();
+ Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
+ }
+ else if (sdCode is SdErrorCode.MaxImageDownloads)
+ {
+ // Max image downloads — stop image requests until SD resets at 00:00 UTC.
+ SetImageLimitHit();
+ }
+ else if (sdCode is SdErrorCode.MaxScheduleRequests)
+ {
+ // Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
+ SetMetadataLimitHit();
+ }
+ else if (enableRetry
+ && (int)response.StatusCode < 500
+ && (sdCode == SdErrorCode.TokenExpired || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is null)))
+ {
+ // 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);
+ retryMessage.Content = message.Content;
+ retryMessage.Headers.TryAddWithoutValidation(
+ "token",
+ await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
+
+ return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
+ }
+
+ throw new HttpRequestException(
+ string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
+ null,
+ response.StatusCode);
}
private async Task<string> GetTokenInternal(
@@ -706,6 +807,163 @@ namespace Jellyfin.LiveTv.Listings
}
}
+ /// <inheritdoc />
+ public async Task<Stream> GetAvailableCountries(CancellationToken cancellationToken)
+ {
+ if (_countriesCache is not null)
+ {
+ return new MemoryStream(_countriesCache, writable: false);
+ }
+
+ 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 new MemoryStream(_countriesCache, writable: false);
+ }
+ 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 new MemoryStream(bytes, writable: false);
+ }
+
+ private static DateOnly? LoadDailyLimitDate(string path)
+ {
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ var text = File.ReadAllText(path).Trim();
+ if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
+ {
+ var dateOnly = DateOnly.FromDateTime(date);
+ if (dateOnly < DateOnly.FromDateTime(DateTime.UtcNow))
+ {
+ // Expired — clean up.
+ File.Delete(path);
+ return null;
+ }
+
+ return dateOnly;
+ }
+ }
+ catch (IOException)
+ {
+ // Corrupt or unreadable — delete and reset.
+ TryDeleteFile(path);
+ }
+
+ return null;
+ }
+
+ /// <inheritdoc />
+ public bool IsServiceAvailable()
+ {
+ if (_accountError)
+ {
+ return false;
+ }
+
+ if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ /// <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 (!_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)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
+ File.WriteAllText(filePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Failed to persist SD daily limit to {Path}", filePath);
+ }
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (IOException)
+ {
+ // Best effort.
+ }
+ }
+
public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
{
if (validateLogin)
@@ -735,11 +993,17 @@ namespace Jellyfin.LiveTv.Listings
public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
{
var listingsId = info.ListingsId;
- ArgumentException.ThrowIfNullOrEmpty(listingsId);
+ if (string.IsNullOrEmpty(listingsId))
+ {
+ return [];
+ }
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
- ArgumentException.ThrowIfNullOrEmpty(token);
+ if (string.IsNullOrEmpty(token))
+ {
+ return [];
+ }
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
options.Headers.TryAddWithoutValidation("token", token);
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs
new file mode 100644
index 0000000000..ceb743f795
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataArrayConverter.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
+
+/// <summary>
+/// Converter for the <c>data</c> field in SD image responses.
+/// The Schedules Direct API may return a non-array value (e.g. a string error message)
+/// instead of the expected image data array for programs with errors.
+/// This converter returns an empty list for any non-array value.
+/// </summary>
+public sealed class ImageDataArrayConverter : JsonConverter<IReadOnlyList<ImageDataDto>>
+{
+ /// <inheritdoc />
+ public override IReadOnlyList<ImageDataDto> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.StartArray)
+ {
+ var result = new List<ImageDataDto>();
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ var item = JsonSerializer.Deserialize<ImageDataDto>(ref reader, options);
+ if (item is not null)
+ {
+ result.Add(item);
+ }
+ }
+
+ return result;
+ }
+
+ // Not an array (string error, null, object, etc.) — skip and return empty.
+ reader.TrySkip();
+ return [];
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, IReadOnlyList<ImageDataDto> value, JsonSerializerOptions options)
+ => JsonSerializer.Serialize(writer, value, options);
+}
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 523900a96a..df96a47e26 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs
@@ -16,9 +16,22 @@ 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")]
+ [JsonConverter(typeof(ImageDataArrayConverter))]
public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>();
}
}
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
index 318c3a2d36..ec2e6cfcc9 100644
--- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
@@ -77,25 +77,39 @@ namespace Jellyfin.LiveTv.Listings
Directory.CreateDirectory(cacheDir);
}
- if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ try
{
- _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
+ if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
- using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
- var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
- var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
- await using (stream.ConfigureAwait(false))
+ using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
+ var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ else
{
- return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
+ var stream = AsyncFile.OpenRead(info.Path);
+ await using (stream.ConfigureAwait(false))
+ {
+ return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
+ }
}
}
- else
+ catch (Exception ex)
{
- var stream = AsyncFile.OpenRead(info.Path);
- await using (stream.ConfigureAwait(false))
+ _logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path);
+
+ if (File.Exists(cacheFile))
{
- return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
+ File.Delete(cacheFile);
}
+
+ throw;
}
}
@@ -128,9 +142,20 @@ namespace Jellyfin.LiveTv.Listings
{
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
+ }
+
+ var fileInfo = new FileInfo(file);
+ if (!fileInfo.Exists || fileInfo.Length == 0)
+ {
+ if (fileInfo.Exists)
+ {
+ File.Delete(file);
+ }
- return file;
+ throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl);
}
+
+ return file;
}
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
index d67f77bc0a..cfd763b6fd 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
@@ -100,6 +101,33 @@ public class TunerHostManager : ITunerHostManager
}
/// <inheritdoc />
+ public void DeleteTunerHost(string? id)
+ {
+ var config = _config.GetLiveTvConfiguration();
+ config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
+ _config.SaveConfiguration("livetv", config);
+
+ // Clean up the disk cache file for this tuner.
+ // Tuner IDs are generated as Guid.NewGuid().ToString("N")
+ // reject anything else so we never use untrusted input in a path or log entry
+ if (Guid.TryParseExact(id, "N", out var tunerGuid))
+ {
+ var safeId = tunerGuid.ToString("N", CultureInfo.InvariantCulture);
+ var channelCacheFile = Path.Combine(_config.CommonApplicationPaths.CachePath, safeId + "_channels");
+ try
+ {
+ File.Delete(channelCacheFile);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Error deleting channel cache file for tuner {TunerId}", safeId);
+ }
+ }
+
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+ }
+
+ /// <inheritdoc />
public async IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly)
{
var configuredDeviceIds = _config.GetLiveTvConfiguration().TunerHosts