diff options
Diffstat (limited to 'src')
30 files changed, 1284 insertions, 1489 deletions
diff --git a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs deleted file mode 100644 index 581fa000d..000000000 --- a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Globalization; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia; - -/// <summary> -/// Represents errors that occur during interaction with Skia codecs. -/// </summary> -public class SkiaCodecException : SkiaException -{ - /// <summary> - /// Initializes a new instance of the <see cref="SkiaCodecException" /> class. - /// </summary> - /// <param name="result">The non-successful codec result returned by Skia.</param> - public SkiaCodecException(SKCodecResult result) - { - CodecResult = result; - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaCodecException" /> class - /// with a specified error message. - /// </summary> - /// <param name="result">The non-successful codec result returned by Skia.</param> - /// <param name="message">The message that describes the error.</param> - public SkiaCodecException(SKCodecResult result, string message) - : base(message) - { - CodecResult = result; - } - - /// <summary> - /// Gets the non-successful codec result returned by Skia. - /// </summary> - public SKCodecResult CodecResult { get; } - - /// <inheritdoc /> - public override string ToString() - => string.Format( - CultureInfo.InvariantCulture, - "Non-success codec result: {0}\n{1}", - CodecResult, - base.ToString()); -} diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 5721e2882..4ae5a9a48 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -182,7 +182,6 @@ public class SkiaEncoder : IImageEncoder /// <inheritdoc /> /// <exception cref="ArgumentNullException">The path is null.</exception> /// <exception cref="FileNotFoundException">The path is not valid.</exception> - /// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception> public string GetImageBlurHash(int xComp, int yComp, string path) { ArgumentException.ThrowIfNullOrEmpty(path); diff --git a/src/Jellyfin.Drawing.Skia/SkiaException.cs b/src/Jellyfin.Drawing.Skia/SkiaException.cs deleted file mode 100644 index d0e69d42c..000000000 --- a/src/Jellyfin.Drawing.Skia/SkiaException.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; - -namespace Jellyfin.Drawing.Skia; - -/// <summary> -/// Represents errors that occur during interaction with Skia. -/// </summary> -public class SkiaException : Exception -{ - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class. - /// </summary> - public SkiaException() - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message. - /// </summary> - /// <param name="message">The message that describes the error.</param> - public SkiaException(string message) : base(message) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a - /// reference to the inner exception that is the cause of this exception. - /// </summary> - /// <param name="message">The error message that explains the reason for the exception.</param> - /// <param name="innerException"> - /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if - /// no inner exception is specified. - /// </param> - public SkiaException(string message, Exception innerException) - : base(message, innerException) - { - } -} diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 65a8f4e83..213328a39 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -7,6 +7,7 @@ using System.Net.Mime; using System.Text; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -38,7 +39,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable private readonly IServerApplicationPaths _appPaths; private readonly IImageEncoder _imageEncoder; - private readonly SemaphoreSlim _parallelEncodingLimit; + private readonly AsyncNonKeyedLocker _parallelEncodingLimit; private bool _disposed; @@ -68,7 +69,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable semaphoreCount = 2 * Environment.ProcessorCount; } - _parallelEncodingLimit = new(semaphoreCount, semaphoreCount); + _parallelEncodingLimit = new(semaphoreCount); } private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images"); @@ -193,18 +194,13 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable { if (!File.Exists(cacheFilePath)) { - // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage - await _parallelEncodingLimit.WaitAsync().ConfigureAwait(false); - string resultPath; - try + + // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage + using (await _parallelEncodingLimit.LockAsync().ConfigureAwait(false)) { resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); } - finally - { - _parallelEncodingLimit.Release(); - } if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index 23c4c0a9a..4a02f90f9 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -21,4 +21,8 @@ <Compile Include="..\..\SharedVersion.cs" /> </ItemGroup> + <ItemGroup> + <PackageReference Include="AsyncKeyedLock" /> + </ItemGroup> + </Project> diff --git a/src/Jellyfin.Extensions/GuidExtensions.cs b/src/Jellyfin.Extensions/GuidExtensions.cs new file mode 100644 index 000000000..95c591a82 --- /dev/null +++ b/src/Jellyfin.Extensions/GuidExtensions.cs @@ -0,0 +1,26 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Extensions; + +/// <summary> +/// Guid specific extensions. +/// </summary> +public static class GuidExtensions +{ + /// <summary> + /// Determine whether the guid is default. + /// </summary> + /// <param name="guid">The guid.</param> + /// <returns>Whether the guid is the default value.</returns> + public static bool IsEmpty(this Guid guid) + => guid.Equals(default); + + /// <summary> + /// Determine whether the guid is null or default. + /// </summary> + /// <param name="guid">The guid.</param> + /// <returns>Whether the guid is null or the default valueF.</returns> + public static bool IsNullOrEmpty([NotNullWhen(false)] this Guid? guid) + => guid is null || guid.Value.IsEmpty(); +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs index 656e3c3da..0a50b5c3b 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs @@ -18,7 +18,7 @@ namespace Jellyfin.Extensions.Json.Converters { // null got handled higher up the call stack var val = value!.Value; - if (val.Equals(default)) + if (val.IsEmpty()) { writer.WriteNullValue(); } diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index f5ce75ff4..1948a9ab9 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -8,12 +8,12 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -50,7 +50,7 @@ namespace Jellyfin.LiveTv.Channels private readonly IFileSystem _fileSystem; private readonly IProviderManager _providerManager; private readonly IMemoryCache _memoryCache; - private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _resourcePool = new(1); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private bool _disposed = false; @@ -114,15 +114,6 @@ namespace Jellyfin.LiveTv.Channels } /// <inheritdoc /> - public bool EnableMediaProbe(BaseItem item) - { - var internalChannel = _libraryManager.GetItemById(item.ChannelId); - var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); - - return channel is ISupportsMediaProbe; - } - - /// <inheritdoc /> public Task DeleteItem(BaseItem item) { var internalChannel = _libraryManager.GetItemById(item.ChannelId); @@ -159,7 +150,7 @@ namespace Jellyfin.LiveTv.Channels /// <inheritdoc /> public async Task<QueryResult<Channel>> GetChannelsInternalAsync(ChannelQuery query) { - var user = query.UserId.Equals(default) + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); @@ -272,7 +263,7 @@ namespace Jellyfin.LiveTv.Channels /// <inheritdoc /> public async Task<QueryResult<BaseItemDto>> GetChannelsAsync(ChannelQuery query) { - var user = query.UserId.Equals(default) + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); @@ -563,18 +554,6 @@ namespace Jellyfin.LiveTv.Channels } /// <summary> - /// Checks whether the provided Guid supports external transfer. - /// </summary> - /// <param name="channelId">The Guid.</param> - /// <returns>Whether or not the provided Guid supports external transfer.</returns> - public bool SupportsExternalTransfer(Guid channelId) - { - var channelProvider = GetChannelProvider(channelId); - - return channelProvider.GetChannelFeatures().SupportsContentDownloading; - } - - /// <summary> /// Gets the provided channel's supported features. /// </summary> /// <param name="channel">The channel.</param> @@ -688,7 +667,7 @@ namespace Jellyfin.LiveTv.Channels ChannelIds = new Guid[] { internalChannel.Id } }; - var result = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + var result = await GetChannelItemsInternal(query, new Progress<double>(), cancellationToken).ConfigureAwait(false); foreach (var item in result.Items) { @@ -701,7 +680,7 @@ namespace Jellyfin.LiveTv.Channels EnableTotalRecordCount = false, ChannelIds = new Guid[] { internalChannel.Id } }, - new SimpleProgress<double>(), + new Progress<double>(), cancellationToken).ConfigureAwait(false); } } @@ -716,7 +695,7 @@ namespace Jellyfin.LiveTv.Channels // Find the corresponding channel provider plugin var channelProvider = GetChannelProvider(channel); - var parentItem = query.ParentId.Equals(default) + var parentItem = query.ParentId.IsEmpty() ? channel : _libraryManager.GetItemById(query.ParentId); @@ -729,7 +708,7 @@ namespace Jellyfin.LiveTv.Channels cancellationToken) .ConfigureAwait(false); - if (query.ParentId.Equals(default)) + if (query.ParentId.IsEmpty()) { query.Parent = channel; } @@ -783,7 +762,7 @@ namespace Jellyfin.LiveTv.Channels /// <inheritdoc /> public async Task<QueryResult<BaseItemDto>> GetChannelItems(InternalItemsQuery query, CancellationToken cancellationToken) { - var internalResult = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + var internalResult = await GetChannelItemsInternal(query, new Progress<double>(), cancellationToken).ConfigureAwait(false); var returnItems = _dtoService.GetBaseItemDtos(internalResult.Items, query.DtoOptions, query.User); @@ -832,9 +811,7 @@ namespace Jellyfin.LiveTv.Channels { } - await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { try { @@ -881,10 +858,6 @@ namespace Jellyfin.LiveTv.Channels return result; } - finally - { - _resourcePool.Release(); - } } private async Task CacheResponse(ChannelItemResult result, string path) @@ -1215,19 +1188,6 @@ namespace Jellyfin.LiveTv.Channels return result; } - internal IChannel GetChannelProvider(Guid internalChannelId) - { - var result = GetAllChannels() - .FirstOrDefault(i => internalChannelId.Equals(GetInternalChannelId(i.Name))); - - if (result is null) - { - throw new ResourceNotFoundException("No channel provider found for channel id " + internalChannelId); - } - - return result; - } - /// <inheritdoc /> public void Dispose() { diff --git a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs index 556e052d4..79c5873d5 100644 --- a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; @@ -66,7 +65,7 @@ namespace Jellyfin.LiveTv.Channels { var manager = (ChannelManager)_channelManager; - await manager.RefreshChannels(new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + await manager.RefreshChannels(new Progress<double>(), cancellationToken).ConfigureAwait(false); await new ChannelPostScanTask(_channelManager, _logger, _libraryManager).Run(progress, cancellationToken) .ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs new file mode 100644 index 000000000..67d0e5295 --- /dev/null +++ b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs @@ -0,0 +1,18 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; + +namespace Jellyfin.LiveTv.Configuration; + +/// <summary> +/// <see cref="IConfigurationManager"/> extensions for Live TV. +/// </summary> +public static class LiveTvConfigurationExtensions +{ + /// <summary> + /// Gets the <see cref="LiveTvOptions"/>. + /// </summary> + /// <param name="configurationManager">The <see cref="IConfigurationManager"/>.</param> + /// <returns>The <see cref="LiveTvOptions"/>.</returns> + public static LiveTvOptions GetLiveTvConfiguration(this IConfigurationManager configurationManager) + => configurationManager.GetConfiguration<LiveTvOptions>("livetv"); +} diff --git a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs new file mode 100644 index 000000000..258afbb05 --- /dev/null +++ b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; + +namespace Jellyfin.LiveTv.Configuration; + +/// <summary> +/// <see cref="IConfigurationFactory" /> implementation for <see cref="LiveTvOptions" />. +/// </summary> +public class LiveTvConfigurationFactory : IConfigurationFactory +{ + /// <inheritdoc /> + public IEnumerable<ConfigurationStore> GetConfigurations() + { + return new[] + { + new ConfigurationStore + { + ConfigurationType = typeof(LiveTvOptions), + Key = "livetv" + } + }; + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 439ed965b..39f334184 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -14,12 +14,13 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; +using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; +using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -43,8 +44,6 @@ namespace Jellyfin.LiveTv.EmbyTV { public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; - private const int TunerDiscoveryDurationMs = 3000; - private readonly ILogger<EmbyTV> _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; @@ -52,7 +51,7 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider; private readonly TimerManager _timerProvider; - private readonly LiveTvManager _liveTvManager; + private readonly ITunerHostManager _tunerHostManager; private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; @@ -61,6 +60,8 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; private readonly IStreamHelper _streamHelper; + private readonly LiveTvDtoService _tvDtoService; + private readonly IListingsProvider[] _listingsProviders; private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase); @@ -68,7 +69,7 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels = new ConcurrentDictionary<string, EpgChannelData>(StringComparer.OrdinalIgnoreCase); - private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1); private bool _disposed; @@ -78,12 +79,14 @@ namespace Jellyfin.LiveTv.EmbyTV ILogger<EmbyTV> logger, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, - ILiveTvManager liveTvManager, + ITunerHostManager tunerHostManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, - IMediaEncoder mediaEncoder) + IMediaEncoder mediaEncoder, + LiveTvDtoService tvDtoService, + IEnumerable<IListingsProvider> listingsProviders) { Current = this; @@ -95,9 +98,11 @@ namespace Jellyfin.LiveTv.EmbyTV _libraryMonitor = libraryMonitor; _providerManager = providerManager; _mediaEncoder = mediaEncoder; - _liveTvManager = (LiveTvManager)liveTvManager; + _tvDtoService = tvDtoService; + _tunerHostManager = tunerHostManager; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; + _listingsProviders = listingsProviders.ToArray(); _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); @@ -126,7 +131,7 @@ namespace Jellyfin.LiveTv.EmbyTV { get { - var path = GetConfiguration().RecordingPath; + var path = _config.GetLiveTvConfiguration().RecordingPath; return string.IsNullOrWhiteSpace(path) ? DefaultRecordingPath @@ -189,7 +194,7 @@ namespace Jellyfin.LiveTv.EmbyTV pathsAdded.AddRange(pathsToCreate); } - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); var pathsToRemove = config.MediaLocationsCreated .Except(recordingFolders.SelectMany(i => i.Locations)) @@ -255,7 +260,7 @@ namespace Jellyfin.LiveTv.EmbyTV if (requiresRefresh) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } } @@ -309,7 +314,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var list = new List<ChannelInfo>(); - foreach (var hostInstance in _liveTvManager.TunerHosts) + foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { @@ -509,7 +514,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var list = new List<ChannelInfo>(); - foreach (var hostInstance in _liveTvManager.TunerHosts) + foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { @@ -831,7 +836,7 @@ namespace Jellyfin.LiveTv.EmbyTV public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) { - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); var defaults = new SeriesTimerInfo() { @@ -932,10 +937,10 @@ namespace Jellyfin.LiveTv.EmbyTV private List<Tuple<IListingsProvider, ListingsProviderInfo>> GetListingProviders() { - return GetConfiguration().ListingProviders + return _config.GetLiveTvConfiguration().ListingProviders .Select(i => { - var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + var provider = _listingsProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); return provider is null ? null : new Tuple<IListingsProvider, ListingsProviderInfo>(provider, i); }) @@ -965,7 +970,7 @@ namespace Jellyfin.LiveTv.EmbyTV return result; } - foreach (var hostInstance in _liveTvManager.TunerHosts) + foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { @@ -997,7 +1002,7 @@ namespace Jellyfin.LiveTv.EmbyTV throw new ArgumentNullException(nameof(channelId)); } - foreach (var hostInstance in _liveTvManager.TunerHosts) + foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { @@ -1021,11 +1026,6 @@ namespace Jellyfin.LiveTv.EmbyTV return Task.CompletedTask; } - public Task RecordLiveStream(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - public Task ResetTuner(string id, CancellationToken cancellationToken) { return Task.CompletedTask; @@ -1076,7 +1076,7 @@ namespace Jellyfin.LiveTv.EmbyTV private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath) { var recordPath = RecordingPath; - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); seriesPath = null; if (timer.IsProgramSeries) @@ -1184,6 +1184,12 @@ namespace Jellyfin.LiveTv.EmbyTV return Path.Combine(recordPath, recordingFileName); } + private BaseItem GetLiveTvChannel(TimerInfo timer) + { + var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId); + return _libraryManager.GetItemById(internalChannelId); + } + private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) { ArgumentNullException.ThrowIfNull(timer); @@ -1209,7 +1215,7 @@ namespace Jellyfin.LiveTv.EmbyTV var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); - var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); + var channelItem = GetLiveTvChannel(timer); string liveStreamId = null; RecordingStatus recordingStatus; @@ -1447,9 +1453,7 @@ namespace Jellyfin.LiveTv.EmbyTV return; } - await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); - - try + using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false)) { if (_disposed) { @@ -1502,10 +1506,6 @@ namespace Jellyfin.LiveTv.EmbyTV } } } - finally - { - _recordingDeleteSemaphore.Release(); - } } private void DeleteLibraryItemsForTimers(List<TimerInfo> timers) @@ -1596,7 +1596,7 @@ namespace Jellyfin.LiveTv.EmbyTV private void PostProcessRecording(TimerInfo timer, string path) { - var options = GetConfiguration(); + var options = _config.GetLiveTvConfiguration(); if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) { return; @@ -1777,7 +1777,7 @@ namespace Jellyfin.LiveTv.EmbyTV program.AddGenre("News"); } - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); if (config.SaveRecordingNFO) { @@ -1995,7 +1995,7 @@ namespace Jellyfin.LiveTv.EmbyTV await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); } - var people = item.Id.Equals(default) ? new List<PersonInfo>() : _libraryManager.GetPeople(item); + var people = item.Id.IsEmpty() ? new List<PersonInfo>() : _libraryManager.GetPeople(item); var directors = people .Where(i => i.IsType(PersonKind.Director)) @@ -2092,7 +2092,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var query = new InternalItemsQuery { - ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) }, + ItemIds = [_tvDtoService.GetInternalProgramId(programId)], Limit = 1, DtoOptions = new DtoOptions() }; @@ -2122,17 +2122,12 @@ namespace Jellyfin.LiveTv.EmbyTV if (!string.IsNullOrWhiteSpace(channelId)) { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) }; + query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)]; } return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault(); } - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration<LiveTvOptions>("livetv"); - } - private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) { if (timer.IsManual) @@ -2163,7 +2158,7 @@ namespace Jellyfin.LiveTv.EmbyTV private void HandleDuplicateShowIds(List<TimerInfo> timers) { // sort showings by HD channels first, then by startDate, record earliest showing possible - foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) + foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1)) { timer.Status = RecordingStatus.Cancelled; _timerProvider.Update(timer); @@ -2313,7 +2308,7 @@ namespace Jellyfin.LiveTv.EmbyTV if (!seriesTimer.RecordAnyChannel) { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) }; + query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)]; } var tempChannelCache = new Dictionary<Guid, LiveTvChannel>(); @@ -2325,7 +2320,7 @@ namespace Jellyfin.LiveTv.EmbyTV { string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; - if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.Equals(default)) + if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.IsEmpty()) { if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) { @@ -2384,7 +2379,7 @@ namespace Jellyfin.LiveTv.EmbyTV { string channelId = null; - if (!programInfo.ChannelId.Equals(default)) + if (!programInfo.ChannelId.IsEmpty()) { if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) { @@ -2519,7 +2514,7 @@ namespace Jellyfin.LiveTv.EmbyTV }; } - var customPath = GetConfiguration().MovieRecordingPath; + var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath; if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) { yield return new VirtualFolderInfo @@ -2530,7 +2525,7 @@ namespace Jellyfin.LiveTv.EmbyTV }; } - customPath = GetConfiguration().SeriesRecordingPath; + customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath; if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) { yield return new VirtualFolderInfo @@ -2541,81 +2536,5 @@ namespace Jellyfin.LiveTv.EmbyTV }; } } - - public async Task<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) - { - var list = new List<TunerHostInfo>(); - - var configuredDeviceIds = GetConfiguration().TunerHosts - .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) - .Select(i => i.DeviceId) - .ToList(); - - foreach (var host in _liveTvManager.TunerHosts) - { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - if (newDevicesOnly) - { - discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - list.AddRange(discoveredDevices); - } - - return list; - } - - public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) - { - foreach (var host in _liveTvManager.TunerHosts) - { - await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); - } - } - - private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) - { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - var configuredDevices = GetConfiguration().TunerHosts - .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var device in discoveredDevices) - { - var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); - - if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); - - configuredDevice.Url = device.Url; - await _liveTvManager.SaveTunerHost(configuredDevice).ConfigureAwait(false); - } - } - } - - private async Task<List<TunerHostInfo>> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) - { - try - { - var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); - - foreach (var device in discoveredDevices) - { - _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); - } - - return discoveredDevices; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error discovering tuner devices"); - - return new List<TunerHostInfo>(); - } - } } } diff --git a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs b/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs deleted file mode 100644 index e750c05ac..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -#pragma warning disable CS1591 - -using System.Threading.Tasks; -using MediaBrowser.Controller.Plugins; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public sealed class EntryPoint : IServerEntryPoint - { - /// <inheritdoc /> - public Task RunAsync() - { - return EmbyTV.Current.Start(); - } - - /// <inheritdoc /> - public void Dispose() - { - } - } -} diff --git a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs new file mode 100644 index 000000000..dc15d53ff --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.LiveTv; +using Microsoft.Extensions.Hosting; + +namespace Jellyfin.LiveTv.EmbyTV; + +/// <summary> +/// <see cref="IHostedService"/> responsible for initializing Live TV. +/// </summary> +public sealed class LiveTvHost : IHostedService +{ + private readonly EmbyTV _service; + + /// <summary> + /// Initializes a new instance of the <see cref="LiveTvHost"/> class. + /// </summary> + /// <param name="services">The available <see cref="ILiveTvService"/>s.</param> + public LiveTvHost(IEnumerable<ILiveTvService> services) + { + _service = services.OfType<EmbyTV>().First(); + } + + /// <inheritdoc /> + public Task StartAsync(CancellationToken cancellationToken) => _service.Start(); + + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs new file mode 100644 index 000000000..a07325ad1 --- /dev/null +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using Jellyfin.LiveTv.Channels; +using Jellyfin.LiveTv.Guide; +using Jellyfin.LiveTv.Listings; +using Jellyfin.LiveTv.TunerHosts; +using Jellyfin.LiveTv.TunerHosts.HdHomerun; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.LiveTv.Extensions; + +/// <summary> +/// Live TV extensions for <see cref="IServiceCollection"/>. +/// </summary> +public static class LiveTvServiceCollectionExtensions +{ + /// <summary> + /// Adds Live TV services to the <see cref="IServiceCollection"/>. + /// </summary> + /// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param> + public static void AddLiveTvServices(this IServiceCollection services) + { + services.AddSingleton<LiveTvDtoService>(); + services.AddSingleton<ILiveTvManager, LiveTvManager>(); + services.AddSingleton<IChannelManager, ChannelManager>(); + services.AddSingleton<IStreamHelper, StreamHelper>(); + services.AddSingleton<ITunerHostManager, TunerHostManager>(); + services.AddSingleton<IGuideManager, GuideManager>(); + + services.AddSingleton<ILiveTvService, EmbyTV.EmbyTV>(); + services.AddSingleton<ITunerHost, HdHomerunHost>(); + services.AddSingleton<ITunerHost, M3UTunerHost>(); + services.AddSingleton<IListingsProvider, SchedulesDirect>(); + services.AddSingleton<IListingsProvider, XmlTvListingsProvider>(); + } +} diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs new file mode 100644 index 000000000..394fbbaea --- /dev/null +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -0,0 +1,707 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using Jellyfin.LiveTv.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Guide; + +/// <inheritdoc /> +public class GuideManager : IGuideManager +{ + private const int MaxGuideDays = 14; + private const string EtagKey = "ProgramEtag"; + private const string ExternalServiceTag = "ExternalServiceId"; + + private readonly ILogger<GuideManager> _logger; + private readonly IConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IItemRepository _itemRepo; + private readonly ILibraryManager _libraryManager; + private readonly ILiveTvManager _liveTvManager; + private readonly ITunerHostManager _tunerHostManager; + private readonly LiveTvDtoService _tvDtoService; + + /// <summary> + /// Initializes a new instance of the <see cref="GuideManager"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param> + /// <param name="config">The <see cref="IConfigurationManager"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> + /// <param name="itemRepo">The <see cref="IItemRepository"/>.</param> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param> + /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param> + /// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param> + public GuideManager( + ILogger<GuideManager> logger, + IConfigurationManager config, + IFileSystem fileSystem, + IItemRepository itemRepo, + ILibraryManager libraryManager, + ILiveTvManager liveTvManager, + ITunerHostManager tunerHostManager, + LiveTvDtoService tvDtoService) + { + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _itemRepo = itemRepo; + _libraryManager = libraryManager; + _liveTvManager = liveTvManager; + _tunerHostManager = tunerHostManager; + _tvDtoService = tvDtoService; + } + + /// <inheritdoc /> + public GuideInfo GetGuideInfo() + { + var startDate = DateTime.UtcNow; + var endDate = startDate.AddDays(GetGuideDays()); + + return new GuideInfo + { + StartDate = startDate, + EndDate = endDate + }; + } + + /// <inheritdoc /> + public async Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(progress); + + await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); + + await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); + + var numComplete = 0; + double progressPerService = _liveTvManager.Services.Count == 0 + ? 0 + : 1.0 / _liveTvManager.Services.Count; + + var newChannelIdList = new List<Guid>(); + var newProgramIdList = new List<Guid>(); + + var cleanDatabase = true; + + foreach (var service in _liveTvManager.Services) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogDebug("Refreshing guide from {Name}", service.Name); + + try + { + var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService)); + + var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); + + newChannelIdList.AddRange(idList.Item1); + newProgramIdList.AddRange(idList.Item2); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + cleanDatabase = false; + _logger.LogError(ex, "Error refreshing channels for service"); + } + + numComplete++; + double percent = numComplete; + percent /= _liveTvManager.Services.Count; + + progress.Report(100 * percent); + } + + if (cleanDatabase) + { + CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken); + CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken); + } + + var coreService = _liveTvManager.Services.OfType<EmbyTV.EmbyTV>().FirstOrDefault(); + if (coreService is not null) + { + await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); + await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); + } + + progress.Report(100); + } + + private double GetGuideDays() + { + var config = _config.GetLiveTvConfiguration(); + + return config.GuideDays.HasValue + ? Math.Clamp(config.GuideDays.Value, 1, MaxGuideDays) + : 7; + } + + private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken) + { + progress.Report(10); + + var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) + .Select(i => new Tuple<string, ChannelInfo>(service.Name, i)) + .ToList(); + + var list = new List<LiveTvChannel>(); + + var numComplete = 0; + var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken); + + foreach (var channelInfo in allChannelsList) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); + + list.Add(item); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); + } + + numComplete++; + double percent = numComplete; + percent /= allChannelsList.Count; + + progress.Report((5 * percent) + 10); + } + + progress.Report(15); + + numComplete = 0; + var programs = new List<Guid>(); + var channels = new List<Guid>(); + + var guideDays = GetGuideDays(); + + _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); + + foreach (var currentChannel in list) + { + cancellationToken.ThrowIfCancellationRequested(); + channels.Add(currentChannel.Id); + + try + { + var start = DateTime.UtcNow.AddHours(-1); + var end = start.AddDays(guideDays); + + var isMovie = false; + var isSports = false; + var isNews = false; + var isKids = false; + var isSeries = false; + + var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); + + var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.LiveTvProgram], + ChannelIds = [currentChannel.Id], + DtoOptions = new DtoOptions(true) + }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); + + var newPrograms = new List<LiveTvProgram>(); + var updatedPrograms = new List<BaseItem>(); + + foreach (var program in channelPrograms) + { + var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel); + if (isNew) + { + newPrograms.Add(programItem); + } + else if (isUpdated) + { + updatedPrograms.Add(programItem); + } + + programs.Add(programItem.Id); + + isMovie |= program.IsMovie; + isSeries |= program.IsSeries; + isSports |= program.IsSports; + isNews |= program.IsNews; + isKids |= program.IsKids; + } + + _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); + + if (newPrograms.Count > 0) + { + _libraryManager.CreateItems(newPrograms, null, cancellationToken); + } + + if (updatedPrograms.Count > 0) + { + await _libraryManager.UpdateItemsAsync( + updatedPrograms, + currentChannel, + ItemUpdateType.MetadataImport, + cancellationToken).ConfigureAwait(false); + } + + currentChannel.IsMovie = isMovie; + currentChannel.IsNews = isNews; + currentChannel.IsSports = isSports; + currentChannel.IsSeries = isSeries; + + if (isKids) + { + currentChannel.AddTag("Kids"); + } + + await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await currentChannel.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); + } + + numComplete++; + double percent = numComplete / (double)allChannelsList.Count; + + progress.Report((85 * percent) + 15); + } + + progress.Report(100); + return new Tuple<List<Guid>, List<Guid>>(channels, programs); + } + + private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) + { + var list = _itemRepo.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = validTypes, + DtoOptions = new DtoOptions(false) + }); + + var numComplete = 0; + + foreach (var itemId in list) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (itemId.IsEmpty()) + { + // Somehow some invalid data got into the db. It probably predates the boundary checking + continue; + } + + if (!currentIdList.Contains(itemId)) + { + var item = _libraryManager.GetItemById(itemId); + + if (item is not null) + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + false); + } + } + + numComplete++; + double percent = numComplete / (double)list.Count; + + progress.Report(100 * percent); + } + } + + private async Task<LiveTvChannel> GetChannel( + ChannelInfo channelInfo, + string serviceName, + BaseItem parentFolder, + CancellationToken cancellationToken) + { + var parentFolderId = parentFolder.Id; + var isNew = false; + var forceUpdate = false; + + var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); + + if (_libraryManager.GetItemById(id) is not LiveTvChannel item) + { + item = new LiveTvChannel + { + Name = channelInfo.Name, + Id = id, + DateCreated = DateTime.UtcNow + }; + + isNew = true; + } + + if (channelInfo.Tags is not null) + { + if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) + { + isNew = true; + } + + item.Tags = channelInfo.Tags; + } + + if (!item.ParentId.Equals(parentFolderId)) + { + isNew = true; + } + + item.ParentId = parentFolderId; + + item.ChannelType = channelInfo.ChannelType; + item.ServiceName = serviceName; + + if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) + { + forceUpdate = true; + } + + item.SetProviderId(ExternalServiceTag, serviceName); + + if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalId = channelInfo.Id; + + if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.Number = channelInfo.Number; + + if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.Name = channelInfo.Name; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); + forceUpdate = true; + } + else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); + forceUpdate = true; + } + } + + if (isNew) + { + _libraryManager.CreateItem(item, parentFolder); + } + else if (forceUpdate) + { + await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + + return item; + } + + private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram( + ProgramInfo info, + Dictionary<Guid, LiveTvProgram> allExistingPrograms, + LiveTvChannel channel) + { + var id = _tvDtoService.GetInternalProgramId(info.Id); + + var isNew = false; + var forceUpdate = false; + + if (!allExistingPrograms.TryGetValue(id, out var item)) + { + isNew = true; + item = new LiveTvProgram + { + Name = info.Name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + if (!string.IsNullOrEmpty(info.Etag)) + { + item.SetProviderId(EtagKey, info.Etag); + } + } + + if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) + { + item.ShowId = info.ShowId; + forceUpdate = true; + } + + var seriesId = info.SeriesId; + + if (!item.ParentId.Equals(channel.Id)) + { + forceUpdate = true; + } + + item.ParentId = channel.Id; + + item.Audio = info.Audio; + item.ChannelId = channel.Id; + item.CommunityRating ??= info.CommunityRating; + if ((item.CommunityRating ?? 0).Equals(0)) + { + item.CommunityRating = null; + } + + item.EpisodeTitle = info.EpisodeTitle; + item.ExternalId = info.Id; + + if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalSeriesId = seriesId; + + var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); + + if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) + { + item.SeriesName = info.Name; + } + + var tags = new List<string>(); + if (info.IsLive) + { + tags.Add("Live"); + } + + if (info.IsPremiere) + { + tags.Add("Premiere"); + } + + if (info.IsNews) + { + tags.Add("News"); + } + + if (info.IsSports) + { + tags.Add("Sports"); + } + + if (info.IsKids) + { + tags.Add("Kids"); + } + + if (info.IsRepeat) + { + tags.Add("Repeat"); + } + + if (info.IsMovie) + { + tags.Add("Movie"); + } + + if (isSeries) + { + tags.Add("Series"); + } + + item.Tags = tags.ToArray(); + + item.Genres = info.Genres.ToArray(); + + if (info.IsHD ?? false) + { + item.Width = 1280; + item.Height = 720; + } + + item.IsMovie = info.IsMovie; + item.IsRepeat = info.IsRepeat; + + if (item.IsSeries != isSeries) + { + forceUpdate = true; + } + + item.IsSeries = isSeries; + + item.Name = info.Name; + item.OfficialRating ??= info.OfficialRating; + item.Overview ??= info.Overview; + item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; + item.ProviderIds = info.ProviderIds; + + foreach (var providerId in info.SeriesProviderIds) + { + info.ProviderIds["Series" + providerId.Key] = providerId.Value; + } + + if (item.StartDate != info.StartDate) + { + forceUpdate = true; + } + + item.StartDate = info.StartDate; + + if (item.EndDate != info.EndDate) + { + forceUpdate = true; + } + + item.EndDate = info.EndDate; + + item.ProductionYear = info.ProductionYear; + + if (!isSeries || info.IsRepeat) + { + item.PremiereDate = info.OriginalAirDate; + } + + item.IndexNumber = info.EpisodeNumber; + item.ParentIndexNumber = info.SeasonNumber; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(info.ImagePath)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ImagePath, + Type = ImageType.Primary + }, + 0); + } + else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ImageUrl, + Type = ImageType.Primary + }, + 0); + } + } + + if (!item.HasImage(ImageType.Thumb)) + { + if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ThumbImageUrl, + Type = ImageType.Thumb + }, + 0); + } + } + + if (!item.HasImage(ImageType.Logo)) + { + if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.LogoImageUrl, + Type = ImageType.Logo + }, + 0); + } + } + + if (!item.HasImage(ImageType.Backdrop)) + { + if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.BackdropImageUrl, + Type = ImageType.Backdrop + }, + 0); + } + } + + var isUpdated = false; + if (isNew) + { + } + else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) + { + isUpdated = true; + } + else + { + var etag = info.Etag; + + if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) + { + item.SetProviderId(EtagKey, etag); + isUpdated = true; + } + } + + if (isNew || isUpdated) + { + item.OnMetadataChanged(); + } + + return (item, isNew, isUpdated); + } +} diff --git a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs new file mode 100644 index 000000000..a9fde0850 --- /dev/null +++ b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Tasks; + +namespace Jellyfin.LiveTv.Guide; + +/// <summary> +/// The "Refresh Guide" scheduled task. +/// </summary> +public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask +{ + private readonly ILiveTvManager _liveTvManager; + private readonly IGuideManager _guideManager; + private readonly IConfigurationManager _config; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class. + /// </summary> + /// <param name="liveTvManager">The live tv manager.</param> + /// <param name="guideManager">The guide manager.</param> + /// <param name="config">The configuration manager.</param> + public RefreshGuideScheduledTask( + ILiveTvManager liveTvManager, + IGuideManager guideManager, + IConfigurationManager config) + { + _liveTvManager = liveTvManager; + _guideManager = guideManager; + _config = config; + } + + /// <inheritdoc /> + public string Name => "Refresh Guide"; + + /// <inheritdoc /> + public string Description => "Downloads channel information from live tv services."; + + /// <inheritdoc /> + public string Category => "Live TV"; + + /// <inheritdoc /> + public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <inheritdoc /> + public string Key => "RefreshGuide"; + + /// <inheritdoc /> + public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + => _guideManager.RefreshGuide(progress, cancellationToken); + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(24).Ticks + } + }; + } +} diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj index 5a826a1da..c58889740 100644 --- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> @@ -11,6 +11,7 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="AsyncKeyedLock" /> <PackageReference Include="Jellyfin.XmlTv" /> <PackageReference Include="System.Linq.Async" /> </ItemGroup> diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 64b64c0ae..c7a57859e 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -16,6 +16,7 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; @@ -35,7 +36,7 @@ namespace Jellyfin.LiveTv.Listings private readonly ILogger<SchedulesDirect> _logger; private readonly IHttpClientFactory _httpClientFactory; - private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _tokenLock = new(1); private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; @@ -568,27 +569,25 @@ namespace Jellyfin.LiveTv.Listings } } - await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); - savedToken.Name = result; - savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); - return result; - } - catch (HttpRequestException ex) + using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false)) { - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + try { - _tokens.Clear(); - _lastErrorResponse = DateTime.UtcNow; + var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); + savedToken.Name = result; + savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); + return result; } + catch (HttpRequestException ex) + { + if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + { + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } - throw; - } - finally - { - _tokenSemaphore.Release(); + throw; + } } } @@ -807,7 +806,7 @@ namespace Jellyfin.LiveTv.Listings if (disposing) { - _tokenSemaphore?.Dispose(); + _tokenLock?.Dispose(); } _disposed = true; diff --git a/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs b/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs deleted file mode 100644 index ddbf6345c..000000000 --- a/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.LiveTv; - -namespace Jellyfin.LiveTv -{ - /// <summary> - /// <see cref="IConfigurationFactory" /> implementation for <see cref="LiveTvOptions" />. - /// </summary> - public class LiveTvConfigurationFactory : IConfigurationFactory - { - /// <inheritdoc /> - public IEnumerable<ConfigurationStore> GetConfigurations() - { - return new ConfigurationStore[] - { - new ConfigurationStore - { - ConfigurationType = typeof(LiveTvOptions), - Key = "livetv" - } - }; - } - } -} diff --git a/src/Jellyfin.LiveTv/LiveTvDtoService.cs b/src/Jellyfin.LiveTv/LiveTvDtoService.cs index 7c7c26eb4..55b056d3d 100644 --- a/src/Jellyfin.LiveTv/LiveTvDtoService.cs +++ b/src/Jellyfin.LiveTv/LiveTvDtoService.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Drawing; @@ -456,7 +457,7 @@ namespace Jellyfin.LiveTv info.Id = timer.ExternalId; } - if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) + if (!dto.ChannelId.IsEmpty() && string.IsNullOrEmpty(info.ChannelId)) { var channel = _libraryManager.GetItemById(dto.ChannelId); @@ -522,7 +523,7 @@ namespace Jellyfin.LiveTv info.Id = timer.ExternalId; } - if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) + if (!dto.ChannelId.IsEmpty() && string.IsNullOrEmpty(info.ChannelId)) { var channel = _libraryManager.GetItemById(dto.ChannelId); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 4fc995653..ef5283b98 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -12,22 +12,19 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; -using MediaBrowser.Common.Configuration; +using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Guide; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; @@ -40,54 +37,49 @@ namespace Jellyfin.LiveTv /// </summary> public class LiveTvManager : ILiveTvManager { - private const int MaxGuideDays = 14; - private const string ExternalServiceTag = "ExternalServiceId"; - - private const string EtagKey = "ProgramEtag"; - private readonly IServerConfigurationManager _config; private readonly ILogger<LiveTvManager> _logger; - private readonly IItemRepository _itemRepo; private readonly IUserManager _userManager; private readonly IDtoService _dtoService; private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; private readonly ITaskManager _taskManager; private readonly ILocalizationManager _localization; - private readonly IFileSystem _fileSystem; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; - - private ILiveTvService[] _services = Array.Empty<ILiveTvService>(); - private ITunerHost[] _tunerHosts = Array.Empty<ITunerHost>(); - private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>(); + private readonly ILiveTvService[] _services; + private readonly IListingsProvider[] _listingProviders; public LiveTvManager( IServerConfigurationManager config, ILogger<LiveTvManager> logger, - IItemRepository itemRepo, IUserDataManager userDataManager, IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, ITaskManager taskManager, ILocalizationManager localization, - IFileSystem fileSystem, IChannelManager channelManager, - LiveTvDtoService liveTvDtoService) + LiveTvDtoService liveTvDtoService, + IEnumerable<ILiveTvService> services, + IEnumerable<IListingsProvider> listingProviders) { _config = config; _logger = logger; - _itemRepo = itemRepo; _userManager = userManager; _libraryManager = libraryManager; _taskManager = taskManager; _localization = localization; - _fileSystem = fileSystem; _dtoService = dtoService; _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; + _services = services.ToArray(); + _listingProviders = listingProviders.ToArray(); + + var defaultService = _services.OfType<EmbyTV.EmbyTV>().First(); + defaultService.TimerCreated += OnEmbyTvTimerCreated; + defaultService.TimerCancelled += OnEmbyTvTimerCancelled; } public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; @@ -104,43 +96,13 @@ namespace Jellyfin.LiveTv /// <value>The services.</value> public IReadOnlyList<ILiveTvService> Services => _services; - public IReadOnlyList<ITunerHost> TunerHosts => _tunerHosts; - public IReadOnlyList<IListingsProvider> ListingProviders => _listingProviders; - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration<LiveTvOptions>("livetv"); - } - public string GetEmbyTvActiveRecordingPath(string id) { return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); } - /// <summary> - /// Adds the parts. - /// </summary> - /// <param name="services">The services.</param> - /// <param name="tunerHosts">The tuner hosts.</param> - /// <param name="listingProviders">The listing providers.</param> - public void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<ITunerHost> tunerHosts, IEnumerable<IListingsProvider> listingProviders) - { - _services = services.ToArray(); - _tunerHosts = tunerHosts.Where(i => i.IsSupported).ToArray(); - - _listingProviders = listingProviders.ToArray(); - - foreach (var service in _services) - { - if (service is EmbyTV.EmbyTV embyTv) - { - embyTv.TimerCreated += OnEmbyTvTimerCreated; - embyTv.TimerCancelled += OnEmbyTvTimerCancelled; - } - } - } - private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs<string> e) { var timerId = e.Argument; @@ -159,20 +121,6 @@ namespace Jellyfin.LiveTv })); } - public List<NameIdPair> GetTunerHostTypes() - { - return _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Type - }).ToList(); - } - - public Task<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) - { - return EmbyTV.EmbyTV.Current.DiscoverTuners(newDevicesOnly, cancellationToken); - } - public QueryResult<BaseItem> GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken) { var user = query.UserId.Equals(default) @@ -425,355 +373,6 @@ namespace Jellyfin.LiveTv } } - private async Task<LiveTvChannel> GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) - { - var parentFolderId = parentFolder.Id; - var isNew = false; - var forceUpdate = false; - - var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); - - var item = _libraryManager.GetItemById(id) as LiveTvChannel; - - if (item is null) - { - item = new LiveTvChannel - { - Name = channelInfo.Name, - Id = id, - DateCreated = DateTime.UtcNow - }; - - isNew = true; - } - - if (channelInfo.Tags is not null) - { - if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) - { - isNew = true; - } - - item.Tags = channelInfo.Tags; - } - - if (!item.ParentId.Equals(parentFolderId)) - { - isNew = true; - } - - item.ParentId = parentFolderId; - - item.ChannelType = channelInfo.ChannelType; - item.ServiceName = serviceName; - - if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) - { - forceUpdate = true; - } - - item.SetProviderId(ExternalServiceTag, serviceName); - - if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalId = channelInfo.Id; - - if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.Number = channelInfo.Number; - - if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.Name = channelInfo.Name; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } - } - - if (isNew) - { - _libraryManager.CreateItem(item, parentFolder); - } - else if (forceUpdate) - { - await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - } - - return item; - } - - private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel) - { - var id = _tvDtoService.GetInternalProgramId(info.Id); - - var isNew = false; - var forceUpdate = false; - - if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item)) - { - isNew = true; - item = new LiveTvProgram - { - Name = info.Name, - Id = id, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow - }; - - if (!string.IsNullOrEmpty(info.Etag)) - { - item.SetProviderId(EtagKey, info.Etag); - } - } - - if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) - { - item.ShowId = info.ShowId; - forceUpdate = true; - } - - var seriesId = info.SeriesId; - - if (!item.ParentId.Equals(channel.Id)) - { - forceUpdate = true; - } - - item.ParentId = channel.Id; - - item.Audio = info.Audio; - item.ChannelId = channel.Id; - item.CommunityRating ??= info.CommunityRating; - if ((item.CommunityRating ?? 0).Equals(0)) - { - item.CommunityRating = null; - } - - item.EpisodeTitle = info.EpisodeTitle; - item.ExternalId = info.Id; - - if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalSeriesId = seriesId; - - var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); - - if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) - { - item.SeriesName = info.Name; - } - - var tags = new List<string>(); - if (info.IsLive) - { - tags.Add("Live"); - } - - if (info.IsPremiere) - { - tags.Add("Premiere"); - } - - if (info.IsNews) - { - tags.Add("News"); - } - - if (info.IsSports) - { - tags.Add("Sports"); - } - - if (info.IsKids) - { - tags.Add("Kids"); - } - - if (info.IsRepeat) - { - tags.Add("Repeat"); - } - - if (info.IsMovie) - { - tags.Add("Movie"); - } - - if (isSeries) - { - tags.Add("Series"); - } - - item.Tags = tags.ToArray(); - - item.Genres = info.Genres.ToArray(); - - if (info.IsHD ?? false) - { - item.Width = 1280; - item.Height = 720; - } - - item.IsMovie = info.IsMovie; - item.IsRepeat = info.IsRepeat; - - if (item.IsSeries != isSeries) - { - forceUpdate = true; - } - - item.IsSeries = isSeries; - - item.Name = info.Name; - item.OfficialRating ??= info.OfficialRating; - item.Overview ??= info.Overview; - item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; - item.ProviderIds = info.ProviderIds; - - foreach (var providerId in info.SeriesProviderIds) - { - info.ProviderIds["Series" + providerId.Key] = providerId.Value; - } - - if (item.StartDate != info.StartDate) - { - forceUpdate = true; - } - - item.StartDate = info.StartDate; - - if (item.EndDate != info.EndDate) - { - forceUpdate = true; - } - - item.EndDate = info.EndDate; - - item.ProductionYear = info.ProductionYear; - - if (!isSeries || info.IsRepeat) - { - item.PremiereDate = info.OriginalAirDate; - } - - item.IndexNumber = info.EpisodeNumber; - item.ParentIndexNumber = info.SeasonNumber; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(info.ImagePath)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImagePath, - Type = ImageType.Primary - }, - 0); - } - else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImageUrl, - Type = ImageType.Primary - }, - 0); - } - } - - if (!item.HasImage(ImageType.Thumb)) - { - if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ThumbImageUrl, - Type = ImageType.Thumb - }, - 0); - } - } - - if (!item.HasImage(ImageType.Logo)) - { - if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.LogoImageUrl, - Type = ImageType.Logo - }, - 0); - } - } - - if (!item.HasImage(ImageType.Backdrop)) - { - if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.BackdropImageUrl, - Type = ImageType.Backdrop - }, - 0); - } - } - - var isUpdated = false; - if (isNew) - { - } - else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) - { - isUpdated = true; - } - else - { - var etag = info.Etag; - - if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) - { - item.SetProviderId(EtagKey, etag); - isUpdated = true; - } - } - - if (isNew || isUpdated) - { - item.OnMetadataChanged(); - } - - return (item, isNew, isUpdated); - } - public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null) { var program = _libraryManager.GetItemById(id); @@ -1025,293 +624,6 @@ namespace Jellyfin.LiveTv } } - internal Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken) - { - return RefreshChannelsInternal(progress, cancellationToken); - } - - private async Task RefreshChannelsInternal(IProgress<double> progress, CancellationToken cancellationToken) - { - await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); - - await EmbyTV.EmbyTV.Current.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); - - var numComplete = 0; - double progressPerService = _services.Length == 0 - ? 0 - : 1.0 / _services.Length; - - var newChannelIdList = new List<Guid>(); - var newProgramIdList = new List<Guid>(); - - var cleanDatabase = true; - - foreach (var service in _services) - { - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogDebug("Refreshing guide from {Name}", service.Name); - - try - { - var innerProgress = new ActionableProgress<double>(); - innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); - - var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); - - newChannelIdList.AddRange(idList.Item1); - newProgramIdList.AddRange(idList.Item2); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - cleanDatabase = false; - _logger.LogError(ex, "Error refreshing channels for service"); - } - - numComplete++; - double percent = numComplete; - percent /= _services.Length; - - progress.Report(100 * percent); - } - - if (cleanDatabase) - { - CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken); - CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken); - } - - var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault(); - - if (coreService is not null) - { - await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); - await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); - } - - // Load these now which will prefetch metadata - var dtoOptions = new DtoOptions(); - var fields = dtoOptions.Fields.ToList(); - dtoOptions.Fields = fields.ToArray(); - - progress.Report(100); - } - - private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress<double> progress, CancellationToken cancellationToken) - { - progress.Report(10); - - var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) - .Select(i => new Tuple<string, ChannelInfo>(service.Name, i)) - .ToList(); - - var list = new List<LiveTvChannel>(); - - var numComplete = 0; - var parentFolder = GetInternalLiveTvFolder(cancellationToken); - - foreach (var channelInfo in allChannelsList) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); - - list.Add(item); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); - } - - numComplete++; - double percent = numComplete; - percent /= allChannelsList.Count; - - progress.Report((5 * percent) + 10); - } - - progress.Report(15); - - numComplete = 0; - var programs = new List<Guid>(); - var channels = new List<Guid>(); - - var guideDays = GetGuideDays(); - - _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); - - cancellationToken.ThrowIfCancellationRequested(); - - foreach (var currentChannel in list) - { - channels.Add(currentChannel.Id); - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var start = DateTime.UtcNow.AddHours(-1); - var end = start.AddDays(guideDays); - - var isMovie = false; - var isSports = false; - var isNews = false; - var isKids = false; - var iSSeries = false; - - var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); - - var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ChannelIds = new Guid[] { currentChannel.Id }, - DtoOptions = new DtoOptions(true) - }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); - - var newPrograms = new List<LiveTvProgram>(); - var updatedPrograms = new List<BaseItem>(); - - foreach (var program in channelPrograms) - { - var programTuple = GetProgram(program, existingPrograms, currentChannel); - var programItem = programTuple.Item; - - if (programTuple.IsNew) - { - newPrograms.Add(programItem); - } - else if (programTuple.IsUpdated) - { - updatedPrograms.Add(programItem); - } - - programs.Add(programItem.Id); - - isMovie |= program.IsMovie; - iSSeries |= program.IsSeries; - isSports |= program.IsSports; - isNews |= program.IsNews; - isKids |= program.IsKids; - } - - _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); - - if (newPrograms.Count > 0) - { - _libraryManager.CreateItems(newPrograms, null, cancellationToken); - } - - if (updatedPrograms.Count > 0) - { - await _libraryManager.UpdateItemsAsync( - updatedPrograms, - currentChannel, - ItemUpdateType.MetadataImport, - cancellationToken).ConfigureAwait(false); - } - - currentChannel.IsMovie = isMovie; - currentChannel.IsNews = isNews; - currentChannel.IsSports = isSports; - currentChannel.IsSeries = iSSeries; - - if (isKids) - { - currentChannel.AddTag("Kids"); - } - - await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - await currentChannel.RefreshMetadata( - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ForceSave = true - }, - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); - } - - numComplete++; - double percent = numComplete / (double)allChannelsList.Count; - - progress.Report((85 * percent) + 15); - } - - progress.Report(100); - return new Tuple<List<Guid>, List<Guid>>(channels, programs); - } - - private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) - { - var list = _itemRepo.GetItemIdsList(new InternalItemsQuery - { - IncludeItemTypes = validTypes, - DtoOptions = new DtoOptions(false) - }); - - var numComplete = 0; - - foreach (var itemId in list) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (itemId.Equals(default)) - { - // Somehow some invalid data got into the db. It probably predates the boundary checking - continue; - } - - if (!currentIdList.Contains(itemId)) - { - var item = _libraryManager.GetItemById(itemId); - - if (item is not null) - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false, - DeleteFromExternalProvider = false - }, - false); - } - } - - numComplete++; - double percent = numComplete / (double)list.Count; - - progress.Report(100 * percent); - } - } - - private double GetGuideDays() - { - var config = GetConfiguration(); - - if (config.GuideDays.HasValue) - { - return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)); - } - - return 7; - } - private async Task<QueryResult<BaseItem>> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) { if (user is null) @@ -1843,12 +1155,6 @@ namespace Jellyfin.LiveTv return new QueryResult<SeriesTimerInfoDto>(returnArray); } - public BaseItem GetLiveTvChannel(TimerInfo timer, ILiveTvService service) - { - var internalChannelId = _tvDtoService.GetInternalChannelId(service.Name, timer.ChannelId); - return _libraryManager.GetItemById(internalChannelId); - } - public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user) { var now = DateTime.UtcNow; @@ -2081,18 +1387,6 @@ namespace Jellyfin.LiveTv await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false); } - public GuideInfo GetGuideInfo() - { - var startDate = DateTime.UtcNow; - var endDate = startDate.AddDays(GetGuideDays()); - - return new GuideInfo - { - StartDate = startDate, - EndDate = endDate - }; - } - private LiveTvServiceInfo[] GetServiceInfos() { return Services.Select(GetServiceInfo).ToArray(); @@ -2125,7 +1419,7 @@ namespace Jellyfin.LiveTv private bool IsLiveTvEnabled(User user) { - return user.HasPermission(PermissionKind.EnableLiveTvAccess) && (Services.Count > 1 || GetConfiguration().TunerHosts.Length > 0); + return user.HasPermission(PermissionKind.EnableLiveTvAccess) && (Services.Count > 1 || _config.GetLiveTvConfiguration().TunerHosts.Length > 0); } public IEnumerable<User> GetEnabledUsers() @@ -2171,48 +1465,6 @@ namespace Jellyfin.LiveTv return _libraryManager.GetNamedView(name, CollectionType.livetv, name); } - public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true) - { - info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.SerializeToUtf8Bytes(info)); - - var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ResourceNotFoundException(); - } - - if (provider is IConfigurableTunerHost configurable) - { - await configurable.Validate(info).ConfigureAwait(false); - } - - var config = GetConfiguration(); - - var list = config.TunerHosts.ToList(); - var index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); - - if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) - { - info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - list.Add(info); - config.TunerHosts = list.ToArray(); - } - else - { - config.TunerHosts[index] = info; - } - - _config.SaveConfiguration("livetv", config); - - if (dataSourceChanged) - { - _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); - } - - return info; - } - public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings) { // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider @@ -2232,7 +1484,7 @@ namespace Jellyfin.LiveTv await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false); - LiveTvOptions config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); var list = config.ListingProviders.ToList(); int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); @@ -2257,7 +1509,7 @@ namespace Jellyfin.LiveTv public void DeleteListingsProvider(string id) { - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); @@ -2267,7 +1519,7 @@ namespace Jellyfin.LiveTv public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) { - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); @@ -2327,7 +1579,7 @@ namespace Jellyfin.LiveTv public Task<List<NameIdPair>> GetLineups(string providerType, string providerId, string country, string location) { - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); if (string.IsNullOrWhiteSpace(providerId)) { @@ -2357,27 +1609,17 @@ namespace Jellyfin.LiveTv public Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken) { - var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); + var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken); } public Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken) { - var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); + var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); var provider = _listingProviders.First(i => string.Equals(i.Type, info.Type, StringComparison.OrdinalIgnoreCase)); return provider.GetChannels(info, cancellationToken); } - public Guid GetInternalChannelId(string serviceName, string externalId) - { - return _tvDtoService.GetInternalChannelId(serviceName, externalId); - } - - public Guid GetInternalProgramId(string externalId) - { - return _tvDtoService.GetInternalProgramId(externalId); - } - /// <inheritdoc /> public Task<BaseItem[]> GetRecordingFoldersAsync(User user) => GetRecordingFoldersAsync(user, false); diff --git a/src/Jellyfin.LiveTv/RecordingNotifier.cs b/src/Jellyfin.LiveTv/RecordingNotifier.cs index 2923948eb..226d525e7 100644 --- a/src/Jellyfin.LiveTv/RecordingNotifier.cs +++ b/src/Jellyfin.LiveTv/RecordingNotifier.cs @@ -1,7 +1,3 @@ -#nullable disable - -#pragma warning disable CS1591 - using System; using System.Linq; using System.Threading; @@ -10,34 +6,44 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Jellyfin.LiveTv { - public sealed class RecordingNotifier : IServerEntryPoint + /// <summary> + /// <see cref="IHostedService"/> responsible for notifying users when a LiveTV recording is completed. + /// </summary> + public sealed class RecordingNotifier : IHostedService { - private readonly ILiveTvManager _liveTvManager; + private readonly ILogger<RecordingNotifier> _logger; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; - private readonly ILogger<RecordingNotifier> _logger; + private readonly ILiveTvManager _liveTvManager; + /// <summary> + /// Initializes a new instance of the <see cref="RecordingNotifier"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger"/>.</param> + /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param> + /// <param name="userManager">The <see cref="IUserManager"/>.</param> + /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param> public RecordingNotifier( + ILogger<RecordingNotifier> logger, ISessionManager sessionManager, IUserManager userManager, - ILogger<RecordingNotifier> logger, ILiveTvManager liveTvManager) { + _logger = logger; _sessionManager = sessionManager; _userManager = userManager; - _logger = logger; _liveTvManager = liveTvManager; } /// <inheritdoc /> - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) { _liveTvManager.TimerCancelled += OnLiveTvManagerTimerCancelled; _liveTvManager.SeriesTimerCancelled += OnLiveTvManagerSeriesTimerCancelled; @@ -47,29 +53,35 @@ namespace Jellyfin.LiveTv return Task.CompletedTask; } - private async void OnLiveTvManagerSeriesTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e) + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) { - await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); - } + _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; + _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; + _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; + _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; - private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e) - { - await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); + return Task.CompletedTask; } - private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e) - { - await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); - } + private async void OnLiveTvManagerSeriesTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e) + => await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); - private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e) - { - await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); - } + private async void OnLiveTvManagerTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e) + => await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerSeriesTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e) + => await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e) + => await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); private async Task SendMessage(SessionMessageType name, TimerEventInfo info) { - var users = _userManager.Users.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)).Select(i => i.Id).ToList(); + var users = _userManager.Users + .Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)) + .Select(i => i.Id) + .ToList(); try { @@ -80,14 +92,5 @@ namespace Jellyfin.LiveTv _logger.LogError(ex, "Error sending message"); } } - - /// <inheritdoc /> - public void Dispose() - { - _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; - _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; - _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; - _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; - } } } diff --git a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs deleted file mode 100644 index e58296a70..000000000 --- a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Tasks; - -namespace Jellyfin.LiveTv -{ - /// <summary> - /// The "Refresh Guide" scheduled task. - /// </summary> - public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask - { - private readonly ILiveTvManager _liveTvManager; - private readonly IConfigurationManager _config; - - /// <summary> - /// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class. - /// </summary> - /// <param name="liveTvManager">The live tv manager.</param> - /// <param name="config">The configuration manager.</param> - public RefreshGuideScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) - { - _liveTvManager = liveTvManager; - _config = config; - } - - /// <inheritdoc /> - public string Name => "Refresh Guide"; - - /// <inheritdoc /> - public string Description => "Downloads channel information from live tv services."; - - /// <inheritdoc /> - public string Category => "Live TV"; - - /// <inheritdoc /> - public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; - - /// <inheritdoc /> - public string Key => "RefreshGuide"; - - /// <inheritdoc /> - public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - var manager = (LiveTvManager)_liveTvManager; - - return manager.RefreshChannels(progress, cancellationToken); - } - - /// <inheritdoc /> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new[] - { - // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } - }; - } - - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration<LiveTvOptions>("livetv"); - } - } -} diff --git a/src/Jellyfin.LiveTv/StreamHelper.cs b/src/Jellyfin.LiveTv/StreamHelper.cs index ab4b6e9b1..e9644e95e 100644 --- a/src/Jellyfin.LiveTv/StreamHelper.cs +++ b/src/Jellyfin.LiveTv/StreamHelper.cs @@ -81,36 +81,6 @@ namespace Jellyfin.LiveTv } } - public async Task CopyToAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken) - { - byte[] buffer = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize); - try - { - int bytesRead; - - while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) - { - var bytesToWrite = Math.Min(bytesRead, copyLength); - - if (bytesToWrite > 0) - { - await destination.WriteAsync(buffer.AsMemory(0, Convert.ToInt32(bytesToWrite)), cancellationToken).ConfigureAwait(false); - } - - copyLength -= bytesToWrite; - - if (copyLength <= 0) - { - break; - } - } - } - finally - { - ArrayPool<byte>.Shared.Return(buffer); - } - } - public async Task CopyUntilCancelled(Stream source, Stream target, int bufferSize, CancellationToken cancellationToken) { byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize); diff --git a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs index 769f196bd..afc2e4f9c 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs @@ -10,7 +10,7 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; +using Jellyfin.LiveTv.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; @@ -69,7 +69,7 @@ namespace Jellyfin.LiveTv.TunerHosts protected virtual IList<TunerHostInfo> GetTunerHosts() { - return GetConfiguration().TunerHosts + return Config.GetLiveTvConfiguration().TunerHosts .Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -228,10 +228,5 @@ namespace Jellyfin.LiveTv.TunerHosts return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase); } - - protected LiveTvOptions GetConfiguration() - { - return Config.GetConfiguration<LiveTvOptions>("livetv"); - } } } diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index b1b08e992..fef84dd00 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Net; using System.Net.Http; @@ -163,152 +162,6 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun } } - private async Task<List<LiveTvTunerInfo>> GetTunerInfosHttp(TunerHostInfo info, CancellationToken cancellationToken) - { - var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) - .ConfigureAwait(false); - var tuners = new List<LiveTvTunerInfo>(); - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - using var sr = new StreamReader(stream, System.Text.Encoding.UTF8); - await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false)) - { - string stripedLine = StripXML(line); - if (stripedLine.Contains("Channel", StringComparison.Ordinal)) - { - LiveTvTunerStatus status; - var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); - var name = stripedLine.Substring(0, index - 1); - var currentChannel = stripedLine.Substring(index + 7); - if (string.Equals(currentChannel, "none", StringComparison.Ordinal)) - { - status = LiveTvTunerStatus.LiveTv; - } - else - { - status = LiveTvTunerStatus.Available; - } - - tuners.Add(new LiveTvTunerInfo - { - Name = name, - SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, - ProgramName = currentChannel, - Status = status - }); - } - } - } - - return tuners; - } - - private static string StripXML(string source) - { - if (string.IsNullOrEmpty(source)) - { - return string.Empty; - } - - char[] buffer = new char[source.Length]; - int bufferIndex = 0; - bool inside = false; - - for (int i = 0; i < source.Length; i++) - { - char let = source[i]; - if (let == '<') - { - inside = true; - continue; - } - - if (let == '>') - { - inside = false; - continue; - } - - if (!inside) - { - buffer[bufferIndex++] = let; - } - } - - return new string(buffer, 0, bufferIndex); - } - - private async Task<List<LiveTvTunerInfo>> GetTunerInfosUdp(TunerHostInfo info, CancellationToken cancellationToken) - { - var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - - var tuners = new List<LiveTvTunerInfo>(model.TunerCount); - - var uri = new Uri(GetApiUrl(info)); - - using (var manager = new HdHomerunManager()) - { - // Legacy HdHomeruns are IPv4 only - var ipInfo = IPAddress.Parse(uri.Host); - - for (int i = 0; i < model.TunerCount; i++) - { - var name = string.Format(CultureInfo.InvariantCulture, "Tuner {0}", i + 1); - var currentChannel = "none"; // TODO: Get current channel and map back to Station Id - var isAvailable = await manager.CheckTunerAvailability(ipInfo, i, cancellationToken).ConfigureAwait(false); - var status = isAvailable ? LiveTvTunerStatus.Available : LiveTvTunerStatus.LiveTv; - tuners.Add(new LiveTvTunerInfo - { - Name = name, - SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, - ProgramName = currentChannel, - Status = status - }); - } - } - - return tuners; - } - - public async Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken) - { - var list = new List<LiveTvTunerInfo>(); - - foreach (var host in GetConfiguration().TunerHosts - .Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase))) - { - try - { - list.AddRange(await GetTunerInfos(host, cancellationToken).ConfigureAwait(false)); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error getting tuner info"); - } - } - - return list; - } - - public async Task<List<LiveTvTunerInfo>> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken) - { - // TODO Need faster way to determine UDP vs HTTP - var channels = await GetChannels(info, true, cancellationToken).ConfigureAwait(false); - - var hdHomerunChannelInfo = channels.FirstOrDefault() as HdHomerunChannelInfo; - - if (hdHomerunChannelInfo is null || hdHomerunChannelInfo.IsLegacyTuner) - { - return await GetTunerInfosUdp(info, cancellationToken).ConfigureAwait(false); - } - - return await GetTunerInfosHttp(info, cancellationToken).ConfigureAwait(false); - } - private static string GetApiUrl(TunerHostInfo info) { var url = info.Url; @@ -574,40 +427,24 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun _streamHelper); } - var enableHttpStream = true; - if (enableHttpStream) - { - mediaSource.Protocol = MediaProtocol.Http; - - var httpUrl = channel.Path; - - // If raw was used, the tuner doesn't support params - if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase)) - { - httpUrl += "?transcode=" + profile; - } + mediaSource.Protocol = MediaProtocol.Http; - mediaSource.Path = httpUrl; + var httpUrl = channel.Path; - return new SharedHttpStream( - mediaSource, - tunerHost, - streamId, - FileSystem, - _httpClientFactory, - Logger, - Config, - _appHost, - _streamHelper); + // If raw was used, the tuner doesn't support params + if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase)) + { + httpUrl += "?transcode=" + profile; } - return new HdHomerunUdpStream( + mediaSource.Path = httpUrl; + + return new SharedHttpStream( mediaSource, tunerHost, streamId, - new HdHomerunChannelCommands(hdhomerunChannel.Number, profile), - modelInfo.TunerCount, FileSystem, + _httpClientFactory, Logger, Config, _appHost, diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs index 7235e65b6..3666d342e 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs @@ -80,22 +80,6 @@ namespace Jellyfin.LiveTv.TunerHosts .ConfigureAwait(false); } - public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken) - { - var list = GetTunerHosts() - .Select(i => new LiveTvTunerInfo() - { - Name = Name, - SourceType = Type, - Status = LiveTvTunerStatus.Available, - Id = i.Url.GetMD5().ToString("N", CultureInfo.InvariantCulture), - Url = i.Url - }) - .ToList(); - - return Task.FromResult(list); - } - protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { var tunerCount = tunerHost.TunerCount; diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs new file mode 100644 index 000000000..60be19c68 --- /dev/null +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Guide; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.TunerHosts; + +/// <inheritdoc /> +public class TunerHostManager : ITunerHostManager +{ + private const int TunerDiscoveryDurationMs = 3000; + + private readonly ILogger<TunerHostManager> _logger; + private readonly IConfigurationManager _config; + private readonly ITaskManager _taskManager; + private readonly ITunerHost[] _tunerHosts; + + /// <summary> + /// Initializes a new instance of the <see cref="TunerHostManager"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger{T}"/>.</param> + /// <param name="config">The <see cref="IConfigurationManager"/>.</param> + /// <param name="taskManager">The <see cref="ITaskManager"/>.</param> + /// <param name="tunerHosts">The <see cref="IEnumerable{T}"/>.</param> + public TunerHostManager( + ILogger<TunerHostManager> logger, + IConfigurationManager config, + ITaskManager taskManager, + IEnumerable<ITunerHost> tunerHosts) + { + _logger = logger; + _config = config; + _taskManager = taskManager; + _tunerHosts = tunerHosts.Where(t => t.IsSupported).ToArray(); + } + + /// <inheritdoc /> + public IReadOnlyList<ITunerHost> TunerHosts => _tunerHosts; + + /// <inheritdoc /> + public IEnumerable<NameIdPair> GetTunerHostTypes() + => _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Type + }); + + /// <inheritdoc /> + public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true) + { + info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.SerializeToUtf8Bytes(info))!; + + var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + if (provider is null) + { + throw new ResourceNotFoundException(); + } + + if (provider is IConfigurableTunerHost configurable) + { + await configurable.Validate(info).ConfigureAwait(false); + } + + var config = _config.GetLiveTvConfiguration(); + + var list = config.TunerHosts.ToList(); + var index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) + { + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + list.Add(info); + config.TunerHosts = list.ToArray(); + } + else + { + config.TunerHosts[index] = info; + } + + _config.SaveConfiguration("livetv", config); + + if (dataSourceChanged) + { + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); + } + + return info; + } + + /// <inheritdoc /> + public async IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly) + { + var configuredDeviceIds = _config.GetLiveTvConfiguration().TunerHosts + .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) + .Select(i => i.DeviceId) + .ToList(); + + foreach (var host in _tunerHosts) + { + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, CancellationToken.None).ConfigureAwait(false); + foreach (var tuner in discoveredDevices) + { + if (!newDevicesOnly || !configuredDeviceIds.Contains(tuner.DeviceId, StringComparer.OrdinalIgnoreCase)) + { + yield return tuner; + } + } + } + } + + /// <inheritdoc /> + public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) + { + foreach (var host in _tunerHosts) + { + await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) + { + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); + + var configuredDevices = _config.GetLiveTvConfiguration().TunerHosts + .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var device in discoveredDevices) + { + var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); + + if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); + + configuredDevice.Url = device.Url; + await SaveTunerHost(configuredDevice).ConfigureAwait(false); + } + } + } + + private async Task<IList<TunerHostInfo>> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) + { + try + { + var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); + + foreach (var device in discoveredDevices) + { + _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); + } + + return discoveredDevices; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error discovering tuner devices"); + + return Array.Empty<TunerHostInfo>(); + } + } +} diff --git a/src/Jellyfin.Networking/ExternalPortForwarding.cs b/src/Jellyfin.Networking/PortForwardingHost.cs index df9e43ca9..d01343624 100644 --- a/src/Jellyfin.Networking/ExternalPortForwarding.cs +++ b/src/Jellyfin.Networking/PortForwardingHost.cs @@ -1,7 +1,3 @@ -#nullable disable - -#pragma warning disable CS1591 - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -12,36 +8,34 @@ using System.Threading.Tasks; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Mono.Nat; namespace Jellyfin.Networking; /// <summary> -/// Server entrypoint handling external port forwarding. +/// <see cref="IHostedService"/> responsible for UPnP port forwarding. /// </summary> -public sealed class ExternalPortForwarding : IServerEntryPoint +public sealed class PortForwardingHost : IHostedService, IDisposable { private readonly IServerApplicationHost _appHost; - private readonly ILogger<ExternalPortForwarding> _logger; + private readonly ILogger<PortForwardingHost> _logger; private readonly IServerConfigurationManager _config; + private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new(); - private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new ConcurrentDictionary<IPEndPoint, byte>(); - - private Timer _timer; - private string _configIdentifier; - + private Timer? _timer; + private string? _configIdentifier; private bool _disposed; /// <summary> - /// Initializes a new instance of the <see cref="ExternalPortForwarding"/> class. + /// Initializes a new instance of the <see cref="PortForwardingHost"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="appHost">The application host.</param> /// <param name="config">The configuration manager.</param> - public ExternalPortForwarding( - ILogger<ExternalPortForwarding> logger, + public PortForwardingHost( + ILogger<PortForwardingHost> logger, IServerApplicationHost appHost, IServerConfigurationManager config) { @@ -66,7 +60,7 @@ public sealed class ExternalPortForwarding : IServerEntryPoint .ToString(); } - private void OnConfigurationUpdated(object sender, EventArgs e) + private void OnConfigurationUpdated(object? sender, EventArgs e) { var oldConfigIdentifier = _configIdentifier; _configIdentifier = GetConfigIdentifier(); @@ -79,7 +73,7 @@ public sealed class ExternalPortForwarding : IServerEntryPoint } /// <inheritdoc /> - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) { Start(); @@ -88,6 +82,14 @@ public sealed class ExternalPortForwarding : IServerEntryPoint return Task.CompletedTask; } + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) + { + Stop(); + + return Task.CompletedTask; + } + private void Start() { var config = _config.GetNetworkConfiguration(); @@ -101,7 +103,8 @@ public sealed class ExternalPortForwarding : IServerEntryPoint NatUtility.DeviceFound += OnNatUtilityDeviceFound; NatUtility.StartDiscovery(); - _timer = new Timer((_) => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); + _timer?.Dispose(); + _timer = new Timer(_ => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); } private void Stop() @@ -112,13 +115,23 @@ public sealed class ExternalPortForwarding : IServerEntryPoint NatUtility.DeviceFound -= OnNatUtilityDeviceFound; _timer?.Dispose(); + _timer = null; } - private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e) + private async void OnNatUtilityDeviceFound(object? sender, DeviceEventArgs e) { + ObjectDisposedException.ThrowIf(_disposed, this); + try { - await CreateRules(e.Device).ConfigureAwait(false); + // On some systems the device discovered event seems to fire repeatedly + // This check will help ensure we're not trying to port map the same device over and over + if (!_createdRules.TryAdd(e.Device.DeviceEndpoint, 0)) + { + return; + } + + await Task.WhenAll(CreatePortMaps(e.Device)).ConfigureAwait(false); } catch (Exception ex) { @@ -126,20 +139,6 @@ public sealed class ExternalPortForwarding : IServerEntryPoint } } - private Task CreateRules(INatDevice device) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - // On some systems the device discovered event seems to fire repeatedly - // This check will help ensure we're not trying to port map the same device over and over - if (!_createdRules.TryAdd(device.DeviceEndpoint, 0)) - { - return Task.CompletedTask; - } - - return Task.WhenAll(CreatePortMaps(device)); - } - private IEnumerable<Task> CreatePortMaps(INatDevice device) { var config = _config.GetNetworkConfiguration(); @@ -185,8 +184,6 @@ public sealed class ExternalPortForwarding : IServerEntryPoint _config.ConfigurationUpdated -= OnConfigurationUpdated; - Stop(); - _timer?.Dispose(); _timer = null; |
