mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-05 20:26:33 +02:00
commit
4178e0ebaf
@ -3,7 +3,6 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Mime;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@ -18,8 +17,6 @@ using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
@ -49,12 +46,11 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
private readonly IListingsManager _listingsManager;
|
||||
private readonly IRecordingsManager _recordingsManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IConfigurationManager _configurationManager;
|
||||
private readonly ITranscodeManager _transcodeManager;
|
||||
private readonly ISchedulesDirectService _schedulesDirectService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LiveTvController"/> class.
|
||||
@ -65,12 +61,11 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <param name="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param>
|
||||
/// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
||||
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
|
||||
/// <param name="schedulesDirectService">Instance of the <see cref="ISchedulesDirectService"/> interface.</param>
|
||||
public LiveTvController(
|
||||
ILiveTvManager liveTvManager,
|
||||
IGuideManager guideManager,
|
||||
@ -78,12 +73,11 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
IListingsManager listingsManager,
|
||||
IRecordingsManager recordingsManager,
|
||||
IUserManager userManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IConfigurationManager configurationManager,
|
||||
ITranscodeManager transcodeManager)
|
||||
ITranscodeManager transcodeManager,
|
||||
ISchedulesDirectService schedulesDirectService)
|
||||
{
|
||||
_liveTvManager = liveTvManager;
|
||||
_guideManager = guideManager;
|
||||
@ -91,12 +85,11 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
_listingsManager = listingsManager;
|
||||
_recordingsManager = recordingsManager;
|
||||
_userManager = userManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_configurationManager = configurationManager;
|
||||
_transcodeManager = transcodeManager;
|
||||
_schedulesDirectService = schedulesDirectService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -345,20 +338,6 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
||||
[Obsolete("This endpoint is obsolete.")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries(
|
||||
[FromQuery] string? channelId,
|
||||
[FromQuery] Guid? userId,
|
||||
@ -389,7 +368,6 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
||||
[Obsolete("This endpoint is obsolete.")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId)
|
||||
{
|
||||
return new QueryResult<BaseItemDto>();
|
||||
@ -834,7 +812,6 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[HttpPost("Timers/{timerId}")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
|
||||
public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo)
|
||||
{
|
||||
await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
|
||||
@ -924,7 +901,6 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[HttpPost("SeriesTimers/{timerId}")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
|
||||
public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
|
||||
{
|
||||
await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
|
||||
@ -980,9 +956,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
||||
{
|
||||
var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
|
||||
config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
_configurationManager.SaveConfiguration("livetv", config);
|
||||
_tunerHostManager.DeleteTunerHost(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -1073,13 +1047,8 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||
public async Task<ActionResult> GetSchedulesDirectCountries()
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
// https://json.schedulesdirect.org/20141201/available/countries
|
||||
// Can't dispose the response as it's required up the call chain.
|
||||
var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries"))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json);
|
||||
var stream = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false);
|
||||
return File(stream, MediaTypeNames.Application.Json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
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>
|
||||
IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tuner host by id, cleans up associated caches, and triggers a guide refresh.
|
||||
/// </summary>
|
||||
/// <param name="id">The tuner host id to delete.</param>
|
||||
void DeleteTunerHost(string? id);
|
||||
|
||||
/// <summary>
|
||||
/// Scans for tuner devices that have changed URLs.
|
||||
/// </summary>
|
||||
|
||||
@ -109,7 +109,7 @@ namespace MediaBrowser.Controller.LiveTv
|
||||
{
|
||||
if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number))
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty);
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:0000000000.00000}", number) + "-" + (Name ?? string.Empty);
|
||||
}
|
||||
|
||||
return (Number ?? string.Empty) + "-" + (Name ?? string.Empty);
|
||||
|
||||
@ -774,7 +774,10 @@ namespace Jellyfin.LiveTv
|
||||
}
|
||||
}
|
||||
|
||||
SearchForDuplicateShowIds(enabledTimersForSeries);
|
||||
if (seriesTimer.SkipEpisodesInLibrary)
|
||||
{
|
||||
SearchForDuplicateShowIds(enabledTimersForSeries);
|
||||
}
|
||||
|
||||
if (deleteInvalidTimers)
|
||||
{
|
||||
|
||||
@ -40,7 +40,9 @@ public static class LiveTvServiceCollectionExtensions
|
||||
services.AddSingleton<ILiveTvService, DefaultLiveTvService>();
|
||||
services.AddSingleton<ITunerHost, HdHomerunHost>();
|
||||
services.AddSingleton<ITunerHost, M3UTunerHost>();
|
||||
services.AddSingleton<IListingsProvider, SchedulesDirect>();
|
||||
services.AddSingleton<SchedulesDirect>();
|
||||
services.AddSingleton<IListingsProvider>(s => s.GetRequiredService<SchedulesDirect>());
|
||||
services.AddSingleton<ISchedulesDirectService>(s => s.GetRequiredService<SchedulesDirect>());
|
||||
services.AddSingleton<IListingsProvider, XmlTvListingsProvider>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ public class GuideManager : IGuideManager
|
||||
private readonly ILiveTvManager _liveTvManager;
|
||||
private readonly ITunerHostManager _tunerHostManager;
|
||||
private readonly IRecordingsManager _recordingsManager;
|
||||
private readonly ISchedulesDirectService _schedulesDirectService;
|
||||
private readonly LiveTvDtoService _tvDtoService;
|
||||
|
||||
/// <summary>
|
||||
@ -55,6 +56,7 @@ public class GuideManager : IGuideManager
|
||||
/// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
|
||||
/// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
|
||||
/// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
|
||||
/// <param name="schedulesDirectService">The <see cref="ISchedulesDirectService"/>.</param>
|
||||
/// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
|
||||
public GuideManager(
|
||||
ILogger<GuideManager> logger,
|
||||
@ -65,6 +67,7 @@ public class GuideManager : IGuideManager
|
||||
ILiveTvManager liveTvManager,
|
||||
ITunerHostManager tunerHostManager,
|
||||
IRecordingsManager recordingsManager,
|
||||
ISchedulesDirectService schedulesDirectService,
|
||||
LiveTvDtoService tvDtoService)
|
||||
{
|
||||
_logger = logger;
|
||||
@ -75,6 +78,7 @@ public class GuideManager : IGuideManager
|
||||
_liveTvManager = liveTvManager;
|
||||
_tunerHostManager = tunerHostManager;
|
||||
_recordingsManager = recordingsManager;
|
||||
_schedulesDirectService = schedulesDirectService;
|
||||
_tvDtoService = tvDtoService;
|
||||
}
|
||||
|
||||
@ -723,13 +727,25 @@ public class GuideManager : IGuideManager
|
||||
|
||||
private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
|
||||
{
|
||||
var sdLimitActive = _schedulesDirectService.IsImageDailyLimitActive();
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
programs
|
||||
.Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
|
||||
.Where(p => !sdLimitActive || !p.ImageInfos.All(
|
||||
img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
|
||||
.DistinctBy(p => p.Id),
|
||||
_cacheParallelOptions,
|
||||
async (program, cancellationToken) =>
|
||||
{
|
||||
// Re-check: limit may have been set by a parallel task since the LINQ filter ran.
|
||||
if (_schedulesDirectService.IsImageDailyLimitActive()
|
||||
&& program.ImageInfos.All(
|
||||
img => img.IsLocalFile || img.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < program.ImageInfos.Length; i++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
@ -738,22 +754,31 @@ public class GuideManager : IGuideManager
|
||||
}
|
||||
|
||||
var imageInfo = program.ImageInfos[i];
|
||||
if (!imageInfo.IsLocalFile)
|
||||
if (imageInfo.IsLocalFile)
|
||||
{
|
||||
_logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
|
||||
try
|
||||
{
|
||||
program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
|
||||
program,
|
||||
imageInfo,
|
||||
imageIndex: 0,
|
||||
removeOnFailure: false)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SD downloads once the daily limit has been hit.
|
||||
if (imageInfo.Path.Contains("schedulesdirect", StringComparison.OrdinalIgnoreCase)
|
||||
&& _schedulesDirectService.IsImageDailyLimitActive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
|
||||
try
|
||||
{
|
||||
program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
|
||||
program,
|
||||
imageInfo,
|
||||
imageIndex: 0,
|
||||
removeOnFailure: false)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -74,6 +75,9 @@ public class ListingsManager : IListingsManager
|
||||
}
|
||||
|
||||
_config.SaveConfiguration("livetv", config);
|
||||
|
||||
InvalidateListingsProviderCache(info.Id);
|
||||
|
||||
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
|
||||
|
||||
return info;
|
||||
@ -87,6 +91,12 @@ public class ListingsManager : IListingsManager
|
||||
config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
|
||||
_config.SaveConfiguration("livetv", config);
|
||||
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
InvalidateListingsProviderCache(id);
|
||||
}
|
||||
|
||||
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
|
||||
}
|
||||
|
||||
@ -322,6 +332,35 @@ public class ListingsManager : IListingsManager
|
||||
return channelId;
|
||||
}
|
||||
|
||||
private void InvalidateListingsProviderCache(string providerId)
|
||||
{
|
||||
// Clear in-memory EPG channel cache for this provider
|
||||
_epgChannels.TryRemove(providerId, out _);
|
||||
|
||||
// Provider IDs are generated as Guid.NewGuid().ToString("N")
|
||||
// reject anything else so we never use untrusted input in a path or log entry.
|
||||
if (!Guid.TryParseExact(providerId, "N", out var providerGuid))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the cached XMLTV file so a fresh copy is downloaded
|
||||
var cachePath = _config.CommonApplicationPaths?.CachePath;
|
||||
if (!string.IsNullOrEmpty(cachePath))
|
||||
{
|
||||
var safeId = providerGuid.ToString("N", CultureInfo.InvariantCulture);
|
||||
var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml");
|
||||
try
|
||||
{
|
||||
File.Delete(xmltvCacheFile);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<EpgChannelData> GetEpgChannels(
|
||||
IListingsProvider provider,
|
||||
ListingsProviderInfo info,
|
||||
|
||||
@ -6,6 +6,7 @@ using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
@ -21,6 +22,7 @@ using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using Jellyfin.LiveTv.Guide;
|
||||
using Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
@ -31,30 +33,45 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.LiveTv.Listings
|
||||
{
|
||||
public class SchedulesDirect : IListingsProvider, IDisposable
|
||||
public class SchedulesDirect : IListingsProvider, ISchedulesDirectService, IDisposable
|
||||
{
|
||||
private const string ApiUrl = "https://json.schedulesdirect.org/20141201";
|
||||
private const int CountryCacheDays = 7;
|
||||
|
||||
private readonly ILogger<SchedulesDirect> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly AsyncNonKeyedLocker _tokenLock = new(1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new();
|
||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
private DateTime _lastErrorResponse;
|
||||
private long _lastErrorResponseTicks;
|
||||
private volatile bool _accountError;
|
||||
private bool _disposed = false;
|
||||
|
||||
private byte[] _countriesCache;
|
||||
private DateOnly? _imageLimitHitDate;
|
||||
private DateOnly? _metadataLimitHitDate;
|
||||
|
||||
public SchedulesDirect(
|
||||
ILogger<SchedulesDirect> logger,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IApplicationPaths appPaths)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_appPaths = appPaths;
|
||||
_imageLimitHitDate = LoadDailyLimitDate(ImageLimitFilePath);
|
||||
_metadataLimitHitDate = LoadDailyLimitDate(MetadataLimitFilePath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Schedules Direct";
|
||||
|
||||
private string ImageLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-image-limit.txt");
|
||||
|
||||
private string MetadataLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-metadata-limit.txt");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Type => nameof(SchedulesDirect);
|
||||
|
||||
@ -76,6 +93,11 @@ namespace Jellyfin.LiveTv.Listings
|
||||
|
||||
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsMetadataLimitActive())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(channelId);
|
||||
|
||||
// Normalize incoming input
|
||||
@ -149,7 +171,8 @@ namespace Jellyfin.LiveTv.Listings
|
||||
var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays);
|
||||
if (willBeCached && images is not null)
|
||||
{
|
||||
var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]);
|
||||
var imageIndex = images.FindIndex(i =>
|
||||
i.ProgramId is not null && schedule.ProgramId.StartsWith(i.ProgramId, StringComparison.Ordinal));
|
||||
if (imageIndex > -1)
|
||||
{
|
||||
var programEntry = programDict[schedule.ProgramId];
|
||||
@ -451,39 +474,57 @@ namespace Jellyfin.LiveTv.Listings
|
||||
IReadOnlyList<string> programIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsImageDailyLimitActive())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (programIds.Count == 0)
|
||||
if (string.IsNullOrEmpty(token) || programIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
|
||||
foreach (var i in programIds)
|
||||
// SD API accepts max 500 program IDs per request
|
||||
const int BatchSize = 500;
|
||||
var results = new List<ShowImagesDto>();
|
||||
for (int i = 0; i < programIds.Count; i += BatchSize)
|
||||
{
|
||||
str.Append('"')
|
||||
.Append(i[..10])
|
||||
.Append("\",");
|
||||
var batch = programIds.Skip(i).Take(BatchSize);
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
|
||||
message.Headers.TryAddWithoutValidation("token", token);
|
||||
message.Content = JsonContent.Create(batch, options: _jsonOptions);
|
||||
|
||||
try
|
||||
{
|
||||
var batchResult = await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false);
|
||||
if (batchResult is not null)
|
||||
{
|
||||
foreach (var entry in batchResult)
|
||||
{
|
||||
if (entry.Code.HasValue)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Schedules Direct returned error for program {ProgramId}: code={Code}, message={Message}",
|
||||
entry.ProgramId,
|
||||
entry.Code,
|
||||
entry.Message);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting image info from schedules direct");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove last ,
|
||||
str.Length--;
|
||||
str.Append(']');
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs");
|
||||
message.Headers.TryAddWithoutValidation("token", token);
|
||||
message.Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json);
|
||||
|
||||
try
|
||||
{
|
||||
return await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting image info from schedules direct");
|
||||
|
||||
return [];
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken)
|
||||
@ -546,8 +587,14 @@ namespace Jellyfin.LiveTv.Listings
|
||||
return null;
|
||||
}
|
||||
|
||||
// Avoid hammering SD
|
||||
if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1)
|
||||
// Permanent account error — SD is disabled for this server lifetime.
|
||||
if (_accountError)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout)
|
||||
if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@ -579,10 +626,16 @@ namespace Jellyfin.LiveTv.Listings
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
|
||||
// For 4xx errors not already handled by Request<T>'s SD code logic
|
||||
// (e.g. unparseable response from the /token endpoint), apply a
|
||||
// temporary backoff to avoid hammering SD.
|
||||
if (!_accountError
|
||||
&& ex.StatusCode.HasValue
|
||||
&& (int)ex.StatusCode.Value >= 400
|
||||
&& (int)ex.StatusCode.Value < 500)
|
||||
{
|
||||
_tokens.Clear();
|
||||
_lastErrorResponse = DateTime.UtcNow;
|
||||
Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
|
||||
}
|
||||
|
||||
throw;
|
||||
@ -605,27 +658,75 @@ namespace Jellyfin.LiveTv.Listings
|
||||
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!enableRetry || (int)response.StatusCode >= 500)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Request to {Url} failed with response {Response}",
|
||||
message.RequestUri,
|
||||
await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
throw new HttpRequestException(
|
||||
string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
|
||||
null,
|
||||
response.StatusCode);
|
||||
// Try to extract the Schedules Direct error code from the response body.
|
||||
SdErrorCode? sdCode = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
if (doc.RootElement.TryGetProperty("code", out var codeProp)
|
||||
&& codeProp.TryGetInt32(out var parsedCode)
|
||||
&& Enum.IsDefined((SdErrorCode)parsedCode))
|
||||
{
|
||||
sdCode = (SdErrorCode)parsedCode;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Response body is not valid JSON; sdCode stays null.
|
||||
}
|
||||
|
||||
_tokens.Clear();
|
||||
using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
|
||||
retryMessage.Content = message.Content;
|
||||
retryMessage.Headers.TryAddWithoutValidation(
|
||||
"token",
|
||||
await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
|
||||
_logger.LogError(
|
||||
"Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}",
|
||||
message.RequestUri,
|
||||
(int)response.StatusCode,
|
||||
sdCode?.ToString() ?? "N/A",
|
||||
responseBody);
|
||||
|
||||
return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
|
||||
if (sdCode is SdErrorCode.InvalidUser or SdErrorCode.InvalidHash or SdErrorCode.AccountLocked or SdErrorCode.AccountExpired or SdErrorCode.PasswordRequired)
|
||||
{
|
||||
// Permanent account errors — disable SD for this server lifetime.
|
||||
_logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart", sdCode);
|
||||
_tokens.Clear();
|
||||
_accountError = true;
|
||||
}
|
||||
else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.TemporaryLockout)
|
||||
{
|
||||
// Transient login errors — back off for 30 minutes, then allow retry.
|
||||
_tokens.Clear();
|
||||
Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
|
||||
}
|
||||
else if (sdCode is SdErrorCode.MaxImageDownloads)
|
||||
{
|
||||
// Max image downloads — stop image requests until SD resets at 00:00 UTC.
|
||||
SetImageLimitHit();
|
||||
}
|
||||
else if (sdCode is SdErrorCode.MaxScheduleRequests)
|
||||
{
|
||||
// Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
|
||||
SetMetadataLimitHit();
|
||||
}
|
||||
else if (enableRetry
|
||||
&& (int)response.StatusCode < 500
|
||||
&& (sdCode == SdErrorCode.TokenExpired || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is null)))
|
||||
{
|
||||
// Token expired — clear tokens and retry with a fresh token.
|
||||
// Also retry on 403 with no parseable SD code (legacy/unexpected auth failure).
|
||||
_tokens.Clear();
|
||||
using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
|
||||
retryMessage.Content = message.Content;
|
||||
retryMessage.Headers.TryAddWithoutValidation(
|
||||
"token",
|
||||
await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new HttpRequestException(
|
||||
string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<string> GetTokenInternal(
|
||||
@ -706,6 +807,163 @@ namespace Jellyfin.LiveTv.Listings
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetAvailableCountries(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_countriesCache is not null)
|
||||
{
|
||||
return new MemoryStream(_countriesCache, writable: false);
|
||||
}
|
||||
|
||||
var cachePath = Path.Combine(_appPaths.CachePath, "sd-countries.json");
|
||||
|
||||
if (File.Exists(cachePath)
|
||||
&& DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath) < TimeSpan.FromDays(CountryCacheDays))
|
||||
{
|
||||
try
|
||||
{
|
||||
_countriesCache = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
|
||||
return new MemoryStream(_countriesCache, writable: false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Corrupt or unreadable — delete and re-fetch.
|
||||
TryDeleteFile(cachePath);
|
||||
}
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
using var response = await client.GetAsync(new Uri(ApiUrl + "/available/countries"), cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
|
||||
await File.WriteAllBytesAsync(cachePath, bytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_countriesCache = bytes;
|
||||
return new MemoryStream(bytes, writable: false);
|
||||
}
|
||||
|
||||
private static DateOnly? LoadDailyLimitDate(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var text = File.ReadAllText(path).Trim();
|
||||
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
|
||||
{
|
||||
var dateOnly = DateOnly.FromDateTime(date);
|
||||
if (dateOnly < DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
{
|
||||
// Expired — clean up.
|
||||
File.Delete(path);
|
||||
return null;
|
||||
}
|
||||
|
||||
return dateOnly;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Corrupt or unreadable — delete and reset.
|
||||
TryDeleteFile(path);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsServiceAvailable()
|
||||
{
|
||||
if (_accountError)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsImageDailyLimitActive()
|
||||
{
|
||||
if (!_imageLimitHitDate.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_imageLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
{
|
||||
_imageLimitHitDate = null;
|
||||
TryDeleteFile(ImageLimitFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsMetadataLimitActive()
|
||||
{
|
||||
if (!_metadataLimitHitDate.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_metadataLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
{
|
||||
_metadataLimitHitDate = null;
|
||||
TryDeleteFile(MetadataLimitFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SetImageLimitHit()
|
||||
{
|
||||
_imageLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
PersistDailyLimitFile(ImageLimitFilePath);
|
||||
}
|
||||
|
||||
private void SetMetadataLimitHit()
|
||||
{
|
||||
_metadataLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
PersistDailyLimitFile(MetadataLimitFilePath);
|
||||
}
|
||||
|
||||
private void PersistDailyLimitFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||
File.WriteAllText(filePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to persist SD daily limit to {Path}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
|
||||
{
|
||||
if (validateLogin)
|
||||
@ -735,11 +993,17 @@ namespace Jellyfin.LiveTv.Listings
|
||||
public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
|
||||
{
|
||||
var listingsId = info.ListingsId;
|
||||
ArgumentException.ThrowIfNullOrEmpty(listingsId);
|
||||
if (string.IsNullOrEmpty(listingsId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(token);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
|
||||
options.Headers.TryAddWithoutValidation("token", token);
|
||||
|
||||
@ -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")]
|
||||
public string? ProgramId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SD error code, if the request for this program failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public int? Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SD error message, if the request for this program failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of data.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
[JsonConverter(typeof(ImageDataArrayConverter))]
|
||||
public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,25 +77,39 @@ namespace Jellyfin.LiveTv.Listings
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
}
|
||||
|
||||
if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
|
||||
var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
|
||||
var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var stream = AsyncFile.OpenRead(info.Path);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
var stream = AsyncFile.OpenRead(info.Path);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
_logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path);
|
||||
|
||||
if (File.Exists(cacheFile))
|
||||
{
|
||||
return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,9 +142,20 @@ namespace Jellyfin.LiveTv.Listings
|
||||
{
|
||||
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(file);
|
||||
if (!fileInfo.Exists || fileInfo.Length == 0)
|
||||
{
|
||||
if (fileInfo.Exists)
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@ -99,6 +100,33 @@ public class TunerHostManager : ITunerHostManager
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteTunerHost(string? id)
|
||||
{
|
||||
var config = _config.GetLiveTvConfiguration();
|
||||
config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
_config.SaveConfiguration("livetv", config);
|
||||
|
||||
// Clean up the disk cache file for this tuner.
|
||||
// Tuner IDs are generated as Guid.NewGuid().ToString("N")
|
||||
// reject anything else so we never use untrusted input in a path or log entry
|
||||
if (Guid.TryParseExact(id, "N", out var tunerGuid))
|
||||
{
|
||||
var safeId = tunerGuid.ToString("N", CultureInfo.InvariantCulture);
|
||||
var channelCacheFile = Path.Combine(_config.CommonApplicationPaths.CachePath, safeId + "_channels");
|
||||
try
|
||||
{
|
||||
File.Delete(channelCacheFile);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error deleting channel cache file for tuner {TunerId}", safeId);
|
||||
}
|
||||
}
|
||||
|
||||
_taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user