Merge pull request #16220 from Shadowghost/epg-fixes

Fix EPG issues
This commit is contained in:
Niels van Velzen 2026-05-05 15:53:19 +02:00 committed by GitHub
commit 4178e0ebaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 628 additions and 122 deletions

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Security.Cryptography;
using System.Text;
@ -18,8 +17,6 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
@ -49,12 +46,11 @@ public class LiveTvController : BaseJellyfinApiController
private readonly IListingsManager _listingsManager;
private readonly IRecordingsManager _recordingsManager;
private readonly IUserManager _userManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IConfigurationManager _configurationManager;
private readonly ITranscodeManager _transcodeManager;
private readonly ISchedulesDirectService _schedulesDirectService;
/// <summary>
/// Initializes a new instance of the <see cref="LiveTvController"/> class.
@ -65,12 +61,11 @@ public class LiveTvController : BaseJellyfinApiController
/// <param name="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param>
/// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
/// <param name="schedulesDirectService">Instance of the <see cref="ISchedulesDirectService"/> interface.</param>
public LiveTvController(
ILiveTvManager liveTvManager,
IGuideManager guideManager,
@ -78,12 +73,11 @@ public class LiveTvController : BaseJellyfinApiController
IListingsManager listingsManager,
IRecordingsManager recordingsManager,
IUserManager userManager,
IHttpClientFactory httpClientFactory,
ILibraryManager libraryManager,
IDtoService dtoService,
IMediaSourceManager mediaSourceManager,
IConfigurationManager configurationManager,
ITranscodeManager transcodeManager)
ITranscodeManager transcodeManager,
ISchedulesDirectService schedulesDirectService)
{
_liveTvManager = liveTvManager;
_guideManager = guideManager;
@ -91,12 +85,11 @@ public class LiveTvController : BaseJellyfinApiController
_listingsManager = listingsManager;
_recordingsManager = recordingsManager;
_userManager = userManager;
_httpClientFactory = httpClientFactory;
_libraryManager = libraryManager;
_dtoService = dtoService;
_mediaSourceManager = mediaSourceManager;
_configurationManager = configurationManager;
_transcodeManager = transcodeManager;
_schedulesDirectService = schedulesDirectService;
}
/// <summary>
@ -345,20 +338,6 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)]
[Obsolete("This endpoint is obsolete.")]
[ApiExplorerSettings(IgnoreApi = true)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")]
public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries(
[FromQuery] string? channelId,
[FromQuery] Guid? userId,
@ -389,7 +368,6 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)]
[Obsolete("This endpoint is obsolete.")]
[ApiExplorerSettings(IgnoreApi = true)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId)
{
return new QueryResult<BaseItemDto>();
@ -834,7 +812,6 @@ public class LiveTvController : BaseJellyfinApiController
[HttpPost("Timers/{timerId}")]
[Authorize(Policy = Policies.LiveTvManagement)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo)
{
await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
@ -924,7 +901,6 @@ public class LiveTvController : BaseJellyfinApiController
[HttpPost("SeriesTimers/{timerId}")]
[Authorize(Policy = Policies.LiveTvManagement)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
{
await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
@ -980,9 +956,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteTunerHost([FromQuery] string? id)
{
var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
_configurationManager.SaveConfiguration("livetv", config);
_tunerHostManager.DeleteTunerHost(id);
return NoContent();
}
@ -1073,13 +1047,8 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesFile(MediaTypeNames.Application.Json)]
public async Task<ActionResult> GetSchedulesDirectCountries()
{
var client = _httpClientFactory.CreateClient(NamedClient.Default);
// https://json.schedulesdirect.org/20141201/available/countries
// Can't dispose the response as it's required up the call chain.
var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries"))
.ConfigureAwait(false);
return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json);
var stream = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false);
return File(stream, MediaTypeNames.Application.Json);
}
/// <summary>

View File

@ -0,0 +1,31 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.LiveTv;
/// <summary>
/// Provides Schedules Direct specific operations.
/// </summary>
public interface ISchedulesDirectService
{
/// <summary>
/// Gets the available countries from the Schedules Direct API, using a file cache.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <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();
/// <summary>
/// Gets a value indicating whether the Schedules Direct service is available.
/// Returns <c>false</c> if a permanent account error has occurred or a transient backoff is active.
/// </summary>
/// <returns><c>true</c> if the service can accept requests; otherwise <c>false</c>.</returns>
bool IsServiceAvailable();
}

View File

@ -37,6 +37,12 @@ public interface ITunerHostManager
/// <returns>The <see cref="TunerHostInfo"/>s.</returns>
IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly);
/// <summary>
/// Deletes a tuner host by id, cleans up associated caches, and triggers a guide refresh.
/// </summary>
/// <param name="id">The tuner host id to delete.</param>
void DeleteTunerHost(string? id);
/// <summary>
/// Scans for tuner devices that have changed URLs.
/// </summary>

View File

@ -109,7 +109,7 @@ namespace MediaBrowser.Controller.LiveTv
{
if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number))
{
return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty);
return string.Format(CultureInfo.InvariantCulture, "{0:0000000000.00000}", number) + "-" + (Name ?? string.Empty);
}
return (Number ?? string.Empty) + "-" + (Name ?? string.Empty);

View File

@ -774,7 +774,10 @@ namespace Jellyfin.LiveTv
}
}
SearchForDuplicateShowIds(enabledTimersForSeries);
if (seriesTimer.SkipEpisodesInLibrary)
{
SearchForDuplicateShowIds(enabledTimersForSeries);
}
if (deleteInvalidTimers)
{

View File

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

View File

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

View File

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

View File

@ -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);
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
message.Headers.TryAddWithoutValidation("token", token);
message.Content = JsonContent.Create(batch, options: _jsonOptions);
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;
}
results.Add(entry);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting image info from schedules direct");
}
}
// Remove last ,
str.Length--;
str.Append(']');
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
{
return await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false);
}
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);

View File

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

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,10 +15,23 @@ 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>
[JsonPropertyName("data")]
[JsonConverter(typeof(ImageDataArrayConverter))]
public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>();
}
}

View File

@ -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);
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))
if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
_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))
{
return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
}
}
else
{
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);
}
return file;
}
var fileInfo = new FileInfo(file);
if (!fileInfo.Exists || fileInfo.Length == 0)
{
if (fileInfo.Exists)
{
File.Delete(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)

View File

@ -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;
@ -99,6 +100,33 @@ public class TunerHostManager : ITunerHostManager
return info;
}
/// <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)
{