Merge pull request #16220 from Shadowghost/epg-fixes
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled

Fix EPG issues
This commit is contained in:
Niels van Velzen
2026-05-05 15:53:19 +02:00
committed by GitHub
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.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Net.Mime; using System.Net.Mime;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
@@ -18,8 +17,6 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
@@ -49,12 +46,11 @@ public class LiveTvController : BaseJellyfinApiController
private readonly IListingsManager _listingsManager; private readonly IListingsManager _listingsManager;
private readonly IRecordingsManager _recordingsManager; private readonly IRecordingsManager _recordingsManager;
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService; private readonly IDtoService _dtoService;
private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaSourceManager _mediaSourceManager;
private readonly IConfigurationManager _configurationManager;
private readonly ITranscodeManager _transcodeManager; private readonly ITranscodeManager _transcodeManager;
private readonly ISchedulesDirectService _schedulesDirectService;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="LiveTvController"/> class. /// 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="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param>
/// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> 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="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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> 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="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="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
/// <param name="schedulesDirectService">Instance of the <see cref="ISchedulesDirectService"/> interface.</param>
public LiveTvController( public LiveTvController(
ILiveTvManager liveTvManager, ILiveTvManager liveTvManager,
IGuideManager guideManager, IGuideManager guideManager,
@@ -78,12 +73,11 @@ public class LiveTvController : BaseJellyfinApiController
IListingsManager listingsManager, IListingsManager listingsManager,
IRecordingsManager recordingsManager, IRecordingsManager recordingsManager,
IUserManager userManager, IUserManager userManager,
IHttpClientFactory httpClientFactory,
ILibraryManager libraryManager, ILibraryManager libraryManager,
IDtoService dtoService, IDtoService dtoService,
IMediaSourceManager mediaSourceManager, IMediaSourceManager mediaSourceManager,
IConfigurationManager configurationManager, ITranscodeManager transcodeManager,
ITranscodeManager transcodeManager) ISchedulesDirectService schedulesDirectService)
{ {
_liveTvManager = liveTvManager; _liveTvManager = liveTvManager;
_guideManager = guideManager; _guideManager = guideManager;
@@ -91,12 +85,11 @@ public class LiveTvController : BaseJellyfinApiController
_listingsManager = listingsManager; _listingsManager = listingsManager;
_recordingsManager = recordingsManager; _recordingsManager = recordingsManager;
_userManager = userManager; _userManager = userManager;
_httpClientFactory = httpClientFactory;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_dtoService = dtoService; _dtoService = dtoService;
_mediaSourceManager = mediaSourceManager; _mediaSourceManager = mediaSourceManager;
_configurationManager = configurationManager;
_transcodeManager = transcodeManager; _transcodeManager = transcodeManager;
_schedulesDirectService = schedulesDirectService;
} }
/// <summary> /// <summary>
@@ -345,20 +338,6 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)] [Authorize(Policy = Policies.LiveTvAccess)]
[Obsolete("This endpoint is obsolete.")] [Obsolete("This endpoint is obsolete.")]
[ApiExplorerSettings(IgnoreApi = true)] [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( public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries(
[FromQuery] string? channelId, [FromQuery] string? channelId,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
@@ -389,7 +368,6 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)] [Authorize(Policy = Policies.LiveTvAccess)]
[Obsolete("This endpoint is obsolete.")] [Obsolete("This endpoint is obsolete.")]
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId) public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId)
{ {
return new QueryResult<BaseItemDto>(); return new QueryResult<BaseItemDto>();
@@ -834,7 +812,6 @@ public class LiveTvController : BaseJellyfinApiController
[HttpPost("Timers/{timerId}")] [HttpPost("Timers/{timerId}")]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.LiveTvManagement)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [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) public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo)
{ {
await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
@@ -924,7 +901,6 @@ public class LiveTvController : BaseJellyfinApiController
[HttpPost("SeriesTimers/{timerId}")] [HttpPost("SeriesTimers/{timerId}")]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.LiveTvManagement)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [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) public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
{ {
await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
@@ -980,9 +956,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteTunerHost([FromQuery] string? id) public ActionResult DeleteTunerHost([FromQuery] string? id)
{ {
var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); _tunerHostManager.DeleteTunerHost(id);
config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
_configurationManager.SaveConfiguration("livetv", config);
return NoContent(); return NoContent();
} }
@@ -1073,13 +1047,8 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesFile(MediaTypeNames.Application.Json)] [ProducesFile(MediaTypeNames.Application.Json)]
public async Task<ActionResult> GetSchedulesDirectCountries() public async Task<ActionResult> GetSchedulesDirectCountries()
{ {
var client = _httpClientFactory.CreateClient(NamedClient.Default); var stream = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false);
// https://json.schedulesdirect.org/20141201/available/countries return File(stream, MediaTypeNames.Application.Json);
// 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);
} }
/// <summary> /// <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> /// <returns>The <see cref="TunerHostInfo"/>s.</returns>
IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly); 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> /// <summary>
/// Scans for tuner devices that have changed URLs. /// Scans for tuner devices that have changed URLs.
/// </summary> /// </summary>

View File

@@ -109,7 +109,7 @@ namespace MediaBrowser.Controller.LiveTv
{ {
if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number)) 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); 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) if (deleteInvalidTimers)
{ {

View File

@@ -40,7 +40,9 @@ public static class LiveTvServiceCollectionExtensions
services.AddSingleton<ILiveTvService, DefaultLiveTvService>(); services.AddSingleton<ILiveTvService, DefaultLiveTvService>();
services.AddSingleton<ITunerHost, HdHomerunHost>(); services.AddSingleton<ITunerHost, HdHomerunHost>();
services.AddSingleton<ITunerHost, M3UTunerHost>(); 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>(); services.AddSingleton<IListingsProvider, XmlTvListingsProvider>();
} }
} }

View File

@@ -37,6 +37,7 @@ public class GuideManager : IGuideManager
private readonly ILiveTvManager _liveTvManager; private readonly ILiveTvManager _liveTvManager;
private readonly ITunerHostManager _tunerHostManager; private readonly ITunerHostManager _tunerHostManager;
private readonly IRecordingsManager _recordingsManager; private readonly IRecordingsManager _recordingsManager;
private readonly ISchedulesDirectService _schedulesDirectService;
private readonly LiveTvDtoService _tvDtoService; private readonly LiveTvDtoService _tvDtoService;
/// <summary> /// <summary>
@@ -55,6 +56,7 @@ public class GuideManager : IGuideManager
/// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param> /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
/// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param> /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
/// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</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> /// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
public GuideManager( public GuideManager(
ILogger<GuideManager> logger, ILogger<GuideManager> logger,
@@ -65,6 +67,7 @@ public class GuideManager : IGuideManager
ILiveTvManager liveTvManager, ILiveTvManager liveTvManager,
ITunerHostManager tunerHostManager, ITunerHostManager tunerHostManager,
IRecordingsManager recordingsManager, IRecordingsManager recordingsManager,
ISchedulesDirectService schedulesDirectService,
LiveTvDtoService tvDtoService) LiveTvDtoService tvDtoService)
{ {
_logger = logger; _logger = logger;
@@ -75,6 +78,7 @@ public class GuideManager : IGuideManager
_liveTvManager = liveTvManager; _liveTvManager = liveTvManager;
_tunerHostManager = tunerHostManager; _tunerHostManager = tunerHostManager;
_recordingsManager = recordingsManager; _recordingsManager = recordingsManager;
_schedulesDirectService = schedulesDirectService;
_tvDtoService = tvDtoService; _tvDtoService = tvDtoService;
} }
@@ -723,13 +727,25 @@ public class GuideManager : IGuideManager
private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate) private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
{ {
var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive();
await Parallel.ForEachAsync( await Parallel.ForEachAsync(
programs programs
.Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate) .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), .DistinctBy(p => p.Id),
_cacheParallelOptions, _cacheParallelOptions,
async (program, cancellationToken) => 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++) for (var i = 0; i < program.ImageInfos.Length; i++)
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
@@ -738,22 +754,31 @@ public class GuideManager : IGuideManager
} }
var imageInfo = program.ImageInfos[i]; var imageInfo = program.ImageInfos[i];
if (!imageInfo.IsLocalFile) if (imageInfo.IsLocalFile)
{ {
_logger.LogDebug("Caching image locally: {Url}", imageInfo.Path); continue;
try }
{
program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal( // Skip SD downloads once the daily limit has been hit.
program, if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)
imageInfo, && _schedulesDirectService.IsImageDailyLimitActive())
imageIndex: 0, {
removeOnFailure: false) continue;
.ConfigureAwait(false); }
}
catch (Exception ex) _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
{ try
_logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path); {
} 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); }).ConfigureAwait(false);

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -74,6 +75,9 @@ public class ListingsManager : IListingsManager
} }
_config.SaveConfiguration("livetv", config); _config.SaveConfiguration("livetv", config);
InvalidateListingsProviderCache(info.Id);
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
return info; 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.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
_config.SaveConfiguration("livetv", config); _config.SaveConfiguration("livetv", config);
if (!string.IsNullOrEmpty(id))
{
InvalidateListingsProviderCache(id);
}
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
} }
@@ -322,6 +332,35 @@ public class ListingsManager : IListingsManager
return channelId; 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( private async Task<EpgChannelData> GetEpgChannels(
IListingsProvider provider, IListingsProvider provider,
ListingsProviderInfo info, ListingsProviderInfo info,

View File

@@ -6,6 +6,7 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
@@ -21,6 +22,7 @@ using Jellyfin.Extensions;
using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json;
using Jellyfin.LiveTv.Guide; using Jellyfin.LiveTv.Guide;
using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; using Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.LiveTv;
@@ -31,30 +33,45 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.LiveTv.Listings 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 string ApiUrl = "https://json.schedulesdirect.org/20141201";
private const int CountryCacheDays = 7;
private readonly ILogger<SchedulesDirect> _logger; private readonly ILogger<SchedulesDirect> _logger;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IApplicationPaths _appPaths;
private readonly AsyncNonKeyedLocker _tokenLock = new(1); private readonly AsyncNonKeyedLocker _tokenLock = new(1);
private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new(); private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new();
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private DateTime _lastErrorResponse; private long _lastErrorResponseTicks;
private volatile bool _accountError;
private bool _disposed = false; private bool _disposed = false;
private byte[] _countriesCache;
private DateOnly? _imageLimitHitDate;
private DateOnly? _metadataLimitHitDate;
public SchedulesDirect( public SchedulesDirect(
ILogger<SchedulesDirect> logger, ILogger<SchedulesDirect> logger,
IHttpClientFactory httpClientFactory) IHttpClientFactory httpClientFactory,
IApplicationPaths appPaths)
{ {
_logger = logger; _logger = logger;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_appPaths = appPaths;
_imageLimitHitDate = LoadDailyLimitDate(ImageLimitFilePath);
_metadataLimitHitDate = LoadDailyLimitDate(MetadataLimitFilePath);
} }
/// <inheritdoc /> /// <inheritdoc />
public string Name => "Schedules Direct"; 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 /> /// <inheritdoc />
public string Type => nameof(SchedulesDirect); 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) public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
{ {
if (IsMetadataLimitActive())
{
return [];
}
ArgumentException.ThrowIfNullOrEmpty(channelId); ArgumentException.ThrowIfNullOrEmpty(channelId);
// Normalize incoming input // Normalize incoming input
@@ -149,7 +171,8 @@ namespace Jellyfin.LiveTv.Listings
var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays); var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays);
if (willBeCached && images is not null) 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) if (imageIndex > -1)
{ {
var programEntry = programDict[schedule.ProgramId]; var programEntry = programDict[schedule.ProgramId];
@@ -451,39 +474,57 @@ namespace Jellyfin.LiveTv.Listings
IReadOnlyList<string> programIds, IReadOnlyList<string> programIds,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (IsImageDailyLimitActive())
{
return [];
}
var token = await GetToken(info, cancellationToken).ConfigureAwait(false); var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
if (programIds.Count == 0) if (string.IsNullOrEmpty(token) || programIds.Count == 0)
{ {
return []; return [];
} }
StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); // SD API accepts max 500 program IDs per request
foreach (var i in programIds) const int BatchSize = 500;
var results = new List<ShowImagesDto>();
for (int i = 0; i < programIds.Count; i += BatchSize)
{ {
str.Append('"') var batch = programIds.Skip(i).Take(BatchSize);
.Append(i[..10])
.Append("\","); 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 , return results;
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 [];
}
} }
public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken)
@@ -546,8 +587,14 @@ namespace Jellyfin.LiveTv.Listings
return null; return null;
} }
// Avoid hammering SD // Permanent account error — SD is disabled for this server lifetime.
if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1) 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; return null;
} }
@@ -579,10 +626,16 @@ namespace Jellyfin.LiveTv.Listings
} }
catch (HttpRequestException ex) 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(); _tokens.Clear();
_lastErrorResponse = DateTime.UtcNow; Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
} }
throw; throw;
@@ -605,27 +658,75 @@ namespace Jellyfin.LiveTv.Listings
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false); return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false);
} }
if (!enableRetry || (int)response.StatusCode >= 500) var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
{
_logger.LogError(
"Request to {Url} failed with response {Response}",
message.RequestUri,
await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
throw new HttpRequestException( // Try to extract the Schedules Direct error code from the response body.
string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), SdErrorCode? sdCode = null;
null, try
response.StatusCode); {
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(); _logger.LogError(
using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri); "Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}",
retryMessage.Content = message.Content; message.RequestUri,
retryMessage.Headers.TryAddWithoutValidation( (int)response.StatusCode,
"token", sdCode?.ToString() ?? "N/A",
await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); 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( 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) public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
{ {
if (validateLogin) if (validateLogin)
@@ -735,11 +993,17 @@ namespace Jellyfin.LiveTv.Listings
public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
{ {
var listingsId = info.ListingsId; var listingsId = info.ListingsId;
ArgumentException.ThrowIfNullOrEmpty(listingsId); if (string.IsNullOrEmpty(listingsId))
{
return [];
}
var token = await GetToken(info, cancellationToken).ConfigureAwait(false); 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); using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
options.Headers.TryAddWithoutValidation("token", token); 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")] [JsonPropertyName("programID")]
public string? ProgramId { get; set; } 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> /// <summary>
/// Gets or sets the list of data. /// Gets or sets the list of data.
/// </summary> /// </summary>
[JsonPropertyName("data")] [JsonPropertyName("data")]
[JsonConverter(typeof(ImageDataArrayConverter))]
public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>(); public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>();
} }
} }

View File

@@ -77,25 +77,39 @@ namespace Jellyfin.LiveTv.Listings
Directory.CreateDirectory(cacheDir); 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))
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); _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); _logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path);
await using (stream.ConfigureAwait(false))
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); 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) 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@@ -99,6 +100,33 @@ public class TunerHostManager : ITunerHostManager
return info; 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 /> /// <inheritdoc />
public async IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly) public async IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly)
{ {