mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-27 02:56:54 +01:00
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
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:
@@ -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>
|
||||||
|
|||||||
31
MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs
Normal file
31
MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs
Normal 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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -774,7 +774,10 @@ namespace Jellyfin.LiveTv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchForDuplicateShowIds(enabledTimersForSeries);
|
if (seriesTimer.SkipEpisodesInLibrary)
|
||||||
|
{
|
||||||
|
SearchForDuplicateShowIds(enabledTimersForSeries);
|
||||||
|
}
|
||||||
|
|
||||||
if (deleteInvalidTimers)
|
if (deleteInvalidTimers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user