aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.Extensions/GuidExtensions.cs26
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs2
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs42
-rw-r--r--src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs18
-rw-r--r--src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs24
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs124
-rw-r--r--src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs33
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs709
-rw-r--r--src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs74
-rw-r--r--src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs25
-rw-r--r--src/Jellyfin.LiveTv/LiveTvDtoService.cs5
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs754
-rw-r--r--src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs75
-rw-r--r--src/Jellyfin.LiveTv/StreamHelper.cs30
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs9
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs183
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs16
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs175
18 files changed, 1110 insertions, 1214 deletions
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 bf735ddd0..a7de5c65b 100644
--- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
+++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
@@ -115,15 +115,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);
@@ -160,7 +151,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);
@@ -273,7 +264,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);
@@ -564,18 +555,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>
@@ -717,7 +696,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);
@@ -730,7 +709,7 @@ namespace Jellyfin.LiveTv.Channels
cancellationToken)
.ConfigureAwait(false);
- if (query.ParentId.Equals(default))
+ if (query.ParentId.IsEmpty())
{
query.Parent = channel;
}
@@ -1210,19 +1189,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/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 20ede63b0..a9a0db057 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
+++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
@@ -18,6 +18,7 @@ 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;
@@ -44,8 +45,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;
@@ -54,6 +53,7 @@ namespace Jellyfin.LiveTv.EmbyTV
private readonly TimerManager _timerProvider;
private readonly LiveTvManager _liveTvManager;
+ private readonly ITunerHostManager _tunerHostManager;
private readonly IFileSystem _fileSystem;
private readonly ILibraryMonitor _libraryMonitor;
@@ -80,6 +80,7 @@ namespace Jellyfin.LiveTv.EmbyTV
IHttpClientFactory httpClientFactory,
IServerConfigurationManager config,
ILiveTvManager liveTvManager,
+ ITunerHostManager tunerHostManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
ILibraryMonitor libraryMonitor,
@@ -97,6 +98,7 @@ namespace Jellyfin.LiveTv.EmbyTV
_providerManager = providerManager;
_mediaEncoder = mediaEncoder;
_liveTvManager = (LiveTvManager)liveTvManager;
+ _tunerHostManager = tunerHostManager;
_mediaSourceManager = mediaSourceManager;
_streamHelper = streamHelper;
@@ -127,7 +129,7 @@ namespace Jellyfin.LiveTv.EmbyTV
{
get
{
- var path = GetConfiguration().RecordingPath;
+ var path = _config.GetLiveTvConfiguration().RecordingPath;
return string.IsNullOrWhiteSpace(path)
? DefaultRecordingPath
@@ -190,7 +192,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))
@@ -310,7 +312,7 @@ namespace Jellyfin.LiveTv.EmbyTV
{
var list = new List<ChannelInfo>();
- foreach (var hostInstance in _liveTvManager.TunerHosts)
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
{
try
{
@@ -510,7 +512,7 @@ namespace Jellyfin.LiveTv.EmbyTV
{
var list = new List<ChannelInfo>();
- foreach (var hostInstance in _liveTvManager.TunerHosts)
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
{
try
{
@@ -832,7 +834,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()
{
@@ -933,7 +935,7 @@ 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));
@@ -966,7 +968,7 @@ namespace Jellyfin.LiveTv.EmbyTV
return result;
}
- foreach (var hostInstance in _liveTvManager.TunerHosts)
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
{
try
{
@@ -998,7 +1000,7 @@ namespace Jellyfin.LiveTv.EmbyTV
throw new ArgumentNullException(nameof(channelId));
}
- foreach (var hostInstance in _liveTvManager.TunerHosts)
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
{
try
{
@@ -1022,11 +1024,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;
@@ -1077,7 +1074,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)
@@ -1591,7 +1588,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;
@@ -1772,7 +1769,7 @@ namespace Jellyfin.LiveTv.EmbyTV
program.AddGenre("News");
}
- var config = GetConfiguration();
+ var config = _config.GetLiveTvConfiguration();
if (config.SaveRecordingNFO)
{
@@ -1990,7 +1987,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))
@@ -2123,11 +2120,6 @@ namespace Jellyfin.LiveTv.EmbyTV
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)
@@ -2320,7 +2312,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))
{
@@ -2379,7 +2371,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))
{
@@ -2514,7 +2506,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
@@ -2525,7 +2517,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
@@ -2536,81 +2528,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/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
new file mode 100644
index 000000000..21dab69e0
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
@@ -0,0 +1,33 @@
+using Jellyfin.LiveTv.Channels;
+using Jellyfin.LiveTv.Guide;
+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<ITunerHost, HdHomerunHost>();
+ services.AddSingleton<ITunerHost, M3UTunerHost>();
+ }
+}
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
new file mode 100644
index 000000000..bfbc6d4cc
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -0,0 +1,709 @@
+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.Common.Progress;
+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 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 /= _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, 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 = _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/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..aa3be2048 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,50 +37,38 @@ 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>();
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)
{
_config = config;
_logger = logger;
- _itemRepo = itemRepo;
_userManager = userManager;
_libraryManager = libraryManager;
_taskManager = taskManager;
_localization = localization;
- _fileSystem = fileSystem;
_dtoService = dtoService;
_userDataManager = userDataManager;
_channelManager = channelManager;
@@ -104,30 +89,17 @@ 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)
+ /// <inheritdoc />
+ public void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<IListingsProvider> listingProviders)
{
_services = services.ToArray();
- _tunerHosts = tunerHosts.Where(i => i.IsSupported).ToArray();
_listingProviders = listingProviders.ToArray();
@@ -159,20 +131,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 +383,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 +634,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)
@@ -2081,18 +1403,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 +1435,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 +1481,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 +1500,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 +1525,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 +1535,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 +1595,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,13 +1625,13 @@ 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);
}
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>();
+ }
+ }
+}