2
0
Эх сурвалжийг харах

Merge pull request #10858 from barronpm/livetv-tunerhostmanager

Add ITunerHostManager service and minor LiveTv cleanup
Bond-009 1 жил өмнө
parent
commit
484ccf7f28
28 өөрчлөгдсөн 353 нэмэгдсэн , 910 устгасан
  1. 1 1
      Emby.Server.Implementations/ApplicationHost.cs
  2. 9 12
      Jellyfin.Api/Controllers/LiveTvController.cs
  3. 0 9
      Jellyfin.Server/CoreAppHost.cs
  4. 2 0
      Jellyfin.Server/Startup.cs
  5. 0 7
      MediaBrowser.Controller/Channels/IChannelManager.cs
  6. 1 14
      MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
  7. 0 13
      MediaBrowser.Controller/LiveTv/ILiveTvService.cs
  8. 0 7
      MediaBrowser.Controller/LiveTv/ITunerHost.cs
  9. 46 0
      MediaBrowser.Controller/LiveTv/ITunerHostManager.cs
  10. 0 54
      MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs
  11. 0 77
      MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs
  12. 0 210
      MediaBrowser.Controller/LiveTv/RecordingInfo.cs
  13. 0 16
      MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs
  14. 0 2
      MediaBrowser.Model/IO/IStreamHelper.cs
  15. 0 12
      MediaBrowser.Model/LiveTv/LiveTvTunerStatus.cs
  16. 0 34
      src/Jellyfin.LiveTv/Channels/ChannelManager.cs
  17. 18 0
      src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
  18. 24 0
      src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs
  19. 17 101
      src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
  20. 31 0
      src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
  21. 0 25
      src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs
  22. 16 83
      src/Jellyfin.LiveTv/LiveTvManager.cs
  23. 2 7
      src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs
  24. 0 30
      src/Jellyfin.LiveTv/StreamHelper.cs
  25. 2 7
      src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs
  26. 10 173
      src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
  27. 0 16
      src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
  28. 174 0
      src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs

+ 1 - 1
Emby.Server.Implementations/ApplicationHost.cs

@@ -695,7 +695,7 @@ namespace Emby.Server.Implementations
                 GetExports<IMetadataSaver>(),
                 GetExports<IExternalId>());
 
-            Resolve<ILiveTvManager>().AddParts(GetExports<ILiveTvService>(), GetExports<ITunerHost>(), GetExports<IListingsProvider>());
+            Resolve<ILiveTvManager>().AddParts(GetExports<ILiveTvService>(), GetExports<IListingsProvider>());
 
             Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
         }

+ 9 - 12
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -10,7 +10,6 @@ using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
@@ -43,6 +42,7 @@ namespace Jellyfin.Api.Controllers;
 public class LiveTvController : BaseJellyfinApiController
 {
     private readonly ILiveTvManager _liveTvManager;
+    private readonly ITunerHostManager _tunerHostManager;
     private readonly IUserManager _userManager;
     private readonly IHttpClientFactory _httpClientFactory;
     private readonly ILibraryManager _libraryManager;
@@ -55,6 +55,7 @@ public class LiveTvController : BaseJellyfinApiController
     /// Initializes a new instance of the <see cref="LiveTvController"/> class.
     /// </summary>
     /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param>
+    /// <param name="tunerHostManager">Instance of the <see cref="ITunerHostManager"/> interface.</param>
     /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
     /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
     /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
@@ -64,6 +65,7 @@ public class LiveTvController : BaseJellyfinApiController
     /// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
     public LiveTvController(
         ILiveTvManager liveTvManager,
+        ITunerHostManager tunerHostManager,
         IUserManager userManager,
         IHttpClientFactory httpClientFactory,
         ILibraryManager libraryManager,
@@ -73,6 +75,7 @@ public class LiveTvController : BaseJellyfinApiController
         ITranscodeManager transcodeManager)
     {
         _liveTvManager = liveTvManager;
+        _tunerHostManager = tunerHostManager;
         _userManager = userManager;
         _httpClientFactory = httpClientFactory;
         _libraryManager = libraryManager;
@@ -951,9 +954,7 @@ public class LiveTvController : BaseJellyfinApiController
     [Authorize(Policy = Policies.LiveTvManagement)]
     [ProducesResponseType(StatusCodes.Status200OK)]
     public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
-    {
-        return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
-    }
+        => await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
 
     /// <summary>
     /// Deletes a tuner host.
@@ -1130,10 +1131,8 @@ public class LiveTvController : BaseJellyfinApiController
     [HttpGet("TunerHosts/Types")]
     [Authorize(Policy = Policies.LiveTvAccess)]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes()
-    {
-        return _liveTvManager.GetTunerHostTypes();
-    }
+    public IEnumerable<NameIdPair> GetTunerHostTypes()
+        => _tunerHostManager.GetTunerHostTypes();
 
     /// <summary>
     /// Discover tuners.
@@ -1145,10 +1144,8 @@ public class LiveTvController : BaseJellyfinApiController
     [HttpGet("Tuners/Discover")]
     [Authorize(Policy = Policies.LiveTvManagement)]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
-    {
-        return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false);
-    }
+    public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
+        => _tunerHostManager.DiscoverTuners(newDevicesOnly);
 
     /// <summary>
     /// Gets a live tv recording stream.

+ 0 - 9
Jellyfin.Server/CoreAppHost.cs

@@ -7,7 +7,6 @@ using Jellyfin.Api.WebSocketListeners;
 using Jellyfin.Drawing;
 using Jellyfin.Drawing.Skia;
 using Jellyfin.LiveTv;
-using Jellyfin.LiveTv.Channels;
 using Jellyfin.Server.Implementations;
 using Jellyfin.Server.Implementations.Activity;
 using Jellyfin.Server.Implementations.Devices;
@@ -18,18 +17,15 @@ using Jellyfin.Server.Implementations.Users;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.BaseItemManager;
-using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Controller.Trickplay;
 using MediaBrowser.Model.Activity;
-using MediaBrowser.Model.IO;
 using MediaBrowser.Providers.Lyric;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -101,11 +97,6 @@ namespace Jellyfin.Server
 
             serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
 
-            serviceCollection.AddSingleton<LiveTvDtoService>();
-            serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
-            serviceCollection.AddSingleton<IChannelManager, ChannelManager>();
-            serviceCollection.AddSingleton<IStreamHelper, StreamHelper>();
-
             foreach (var type in GetExportTypes<ILyricProvider>())
             {
                 serviceCollection.AddSingleton(typeof(ILyricProvider), type);

+ 2 - 0
Jellyfin.Server/Startup.cs

@@ -5,6 +5,7 @@ using System.Net.Http.Headers;
 using System.Net.Mime;
 using System.Text;
 using Jellyfin.Api.Middleware;
+using Jellyfin.LiveTv.Extensions;
 using Jellyfin.MediaEncoding.Hls.Extensions;
 using Jellyfin.Networking;
 using Jellyfin.Networking.HappyEyeballs;
@@ -121,6 +122,7 @@ namespace Jellyfin.Server
                 .AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext));
 
             services.AddHlsPlaylistGenerator();
+            services.AddLiveTvServices();
 
             services.AddHostedService<AutoDiscoveryHost>();
         }

+ 0 - 7
MediaBrowser.Controller/Channels/IChannelManager.cs

@@ -95,12 +95,5 @@ namespace MediaBrowser.Controller.Channels
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The item media sources.</returns>
         IEnumerable<MediaSourceInfo> GetStaticMediaSources(BaseItem item, CancellationToken cancellationToken);
-
-        /// <summary>
-        /// Whether the item supports media probe.
-        /// </summary>
-        /// <param name="item">The item.</param>
-        /// <returns>Whether media probe should be enabled.</returns>
-        bool EnableMediaProbe(BaseItem item);
     }
 }

+ 1 - 14
MediaBrowser.Controller/LiveTv/ILiveTvManager.cs

@@ -71,9 +71,8 @@ namespace MediaBrowser.Controller.LiveTv
         /// Adds the parts.
         /// </summary>
         /// <param name="services">The services.</param>
-        /// <param name="tunerHosts">The tuner hosts.</param>
         /// <param name="listingProviders">The listing providers.</param>
-        void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<ITunerHost> tunerHosts, IEnumerable<IListingsProvider> listingProviders);
+        void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<IListingsProvider> listingProviders);
 
         /// <summary>
         /// Gets the timer.
@@ -253,14 +252,6 @@ namespace MediaBrowser.Controller.LiveTv
         /// <returns>Task.</returns>
         Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem Item, BaseItemDto ItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null);
 
-        /// <summary>
-        /// Saves the tuner host.
-        /// </summary>
-        /// <param name="info">Turner host to save.</param>
-        /// <param name="dataSourceChanged">Option to specify that data source has changed.</param>
-        /// <returns>Tuner host information wrapped in a task.</returns>
-        Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true);
-
         /// <summary>
         /// Saves the listing provider.
         /// </summary>
@@ -298,10 +289,6 @@ namespace MediaBrowser.Controller.LiveTv
 
         Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken);
 
-        List<NameIdPair> GetTunerHostTypes();
-
-        Task<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken);
-
         string GetEmbyTvActiveRecordingPath(string id);
 
         ActiveRecordingInfo GetActiveRecordingInfo(string path);

+ 0 - 13
MediaBrowser.Controller/LiveTv/ILiveTvService.cs

@@ -140,14 +140,6 @@ namespace MediaBrowser.Controller.LiveTv
         /// <returns>Task.</returns>
         Task CloseLiveStream(string id, CancellationToken cancellationToken);
 
-        /// <summary>
-        /// Records the live stream.
-        /// </summary>
-        /// <param name="id">The identifier.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <returns>Task.</returns>
-        Task RecordLiveStream(string id, CancellationToken cancellationToken);
-
         /// <summary>
         /// Resets the tuner.
         /// </summary>
@@ -180,9 +172,4 @@ namespace MediaBrowser.Controller.LiveTv
     {
         Task<ILiveStream> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken);
     }
-
-    public interface ISupportsUpdatingDefaults
-    {
-        Task UpdateTimerDefaults(SeriesTimerInfo info, CancellationToken cancellationToken);
-    }
 }

+ 0 - 7
MediaBrowser.Controller/LiveTv/ITunerHost.cs

@@ -35,13 +35,6 @@ namespace MediaBrowser.Controller.LiveTv
         /// <returns>Task&lt;IEnumerable&lt;ChannelInfo&gt;&gt;.</returns>
         Task<List<ChannelInfo>> GetChannels(bool enableCache, CancellationToken cancellationToken);
 
-        /// <summary>
-        /// Gets the tuner infos.
-        /// </summary>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <returns>Task&lt;List&lt;LiveTvTunerInfo&gt;&gt;.</returns>
-        Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken);
-
         /// <summary>
         /// Gets the channel stream.
         /// </summary>

+ 46 - 0
MediaBrowser.Controller/LiveTv/ITunerHostManager.cs

@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+
+namespace MediaBrowser.Controller.LiveTv;
+
+/// <summary>
+/// Service responsible for managing the <see cref="ITunerHost"/>s.
+/// </summary>
+public interface ITunerHostManager
+{
+    /// <summary>
+    /// Gets the available <see cref="ITunerHost"/>s.
+    /// </summary>
+    IReadOnlyList<ITunerHost> TunerHosts { get; }
+
+    /// <summary>
+    /// Gets the <see cref="NameIdPair"/>s for the available <see cref="ITunerHost"/>s.
+    /// </summary>
+    /// <returns>The <see cref="NameIdPair"/>s.</returns>
+    IEnumerable<NameIdPair> GetTunerHostTypes();
+
+    /// <summary>
+    /// Saves the tuner host.
+    /// </summary>
+    /// <param name="info">Turner host to save.</param>
+    /// <param name="dataSourceChanged">Option to specify that data source has changed.</param>
+    /// <returns>Tuner host information wrapped in a task.</returns>
+    Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true);
+
+    /// <summary>
+    /// Discovers the available tuners.
+    /// </summary>
+    /// <param name="newDevicesOnly">A value indicating whether to only return new devices.</param>
+    /// <returns>The <see cref="TunerHostInfo"/>s.</returns>
+    IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly);
+
+    /// <summary>
+    /// Scans for tuner devices that have changed URLs.
+    /// </summary>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/> to use.</param>
+    /// <returns>A task that represents the scanning operation.</returns>
+    Task ScanForTunerDeviceChanges(CancellationToken cancellationToken);
+}

+ 0 - 54
MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs

@@ -1,54 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-using System.Collections.Generic;
-using MediaBrowser.Model.LiveTv;
-
-namespace MediaBrowser.Controller.LiveTv
-{
-    public class LiveTvServiceStatusInfo
-    {
-        public LiveTvServiceStatusInfo()
-        {
-            Tuners = new List<LiveTvTunerInfo>();
-            IsVisible = true;
-        }
-
-        /// <summary>
-        /// Gets or sets the status.
-        /// </summary>
-        /// <value>The status.</value>
-        public LiveTvServiceStatus Status { get; set; }
-
-        /// <summary>
-        /// Gets or sets the status message.
-        /// </summary>
-        /// <value>The status message.</value>
-        public string StatusMessage { get; set; }
-
-        /// <summary>
-        /// Gets or sets the version.
-        /// </summary>
-        /// <value>The version.</value>
-        public string Version { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance has update available.
-        /// </summary>
-        /// <value><c>true</c> if this instance has update available; otherwise, <c>false</c>.</value>
-        public bool HasUpdateAvailable { get; set; }
-
-        /// <summary>
-        /// Gets or sets the tuners.
-        /// </summary>
-        /// <value>The tuners.</value>
-        public List<LiveTvTunerInfo> Tuners { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is visible.
-        /// </summary>
-        /// <value><c>true</c> if this instance is visible; otherwise, <c>false</c>.</value>
-        public bool IsVisible { get; set; }
-    }
-}

+ 0 - 77
MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs

@@ -1,77 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-using System.Collections.Generic;
-using MediaBrowser.Model.LiveTv;
-
-namespace MediaBrowser.Controller.LiveTv
-{
-    public class LiveTvTunerInfo
-    {
-        public LiveTvTunerInfo()
-        {
-            Clients = new List<string>();
-        }
-
-        /// <summary>
-        /// Gets or sets the type of the source.
-        /// </summary>
-        /// <value>The type of the source.</value>
-        public string SourceType { get; set; }
-
-        /// <summary>
-        /// Gets or sets the name.
-        /// </summary>
-        /// <value>The name.</value>
-        public string Name { get; set; }
-
-        /// <summary>
-        /// Gets or sets the identifier.
-        /// </summary>
-        /// <value>The identifier.</value>
-        public string Id { get; set; }
-
-        /// <summary>
-        /// Gets or sets the URL.
-        /// </summary>
-        /// <value>The URL.</value>
-        public string Url { get; set; }
-
-        /// <summary>
-        /// Gets or sets the status.
-        /// </summary>
-        /// <value>The status.</value>
-        public LiveTvTunerStatus Status { get; set; }
-
-        /// <summary>
-        /// Gets or sets the channel identifier.
-        /// </summary>
-        /// <value>The channel identifier.</value>
-        public string ChannelId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the recording identifier.
-        /// </summary>
-        /// <value>The recording identifier.</value>
-        public string RecordingId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the name of the program.
-        /// </summary>
-        /// <value>The name of the program.</value>
-        public string ProgramName { get; set; }
-
-        /// <summary>
-        /// Gets or sets the clients.
-        /// </summary>
-        /// <value>The clients.</value>
-        public List<string> Clients { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance can reset.
-        /// </summary>
-        /// <value><c>true</c> if this instance can reset; otherwise, <c>false</c>.</value>
-        public bool CanReset { get; set; }
-    }
-}

+ 0 - 210
MediaBrowser.Controller/LiveTv/RecordingInfo.cs

@@ -1,210 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using MediaBrowser.Model.LiveTv;
-
-namespace MediaBrowser.Controller.LiveTv
-{
-    public class RecordingInfo
-    {
-        public RecordingInfo()
-        {
-            Genres = new List<string>();
-        }
-
-        /// <summary>
-        /// Gets or sets the id of the recording.
-        /// </summary>
-        public string Id { get; set; }
-
-        /// <summary>
-        /// Gets or sets the series timer identifier.
-        /// </summary>
-        /// <value>The series timer identifier.</value>
-        public string SeriesTimerId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the timer identifier.
-        /// </summary>
-        /// <value>The timer identifier.</value>
-        public string TimerId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the channelId of the recording.
-        /// </summary>
-        public string ChannelId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the type of the channel.
-        /// </summary>
-        /// <value>The type of the channel.</value>
-        public ChannelType ChannelType { get; set; }
-
-        /// <summary>
-        /// Gets or sets the name of the recording.
-        /// </summary>
-        public string Name { get; set; }
-
-        /// <summary>
-        /// Gets or sets the path.
-        /// </summary>
-        /// <value>The path.</value>
-        public string Path { get; set; }
-
-        /// <summary>
-        /// Gets or sets the URL.
-        /// </summary>
-        /// <value>The URL.</value>
-        public string Url { get; set; }
-
-        /// <summary>
-        /// Gets or sets the overview.
-        /// </summary>
-        /// <value>The overview.</value>
-        public string Overview { get; set; }
-
-        /// <summary>
-        /// Gets or sets the start date of the recording, in UTC.
-        /// </summary>
-        public DateTime StartDate { get; set; }
-
-        /// <summary>
-        /// Gets or sets the end date of the recording, in UTC.
-        /// </summary>
-        public DateTime EndDate { get; set; }
-
-        /// <summary>
-        /// Gets or sets the program identifier.
-        /// </summary>
-        /// <value>The program identifier.</value>
-        public string ProgramId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the status.
-        /// </summary>
-        /// <value>The status.</value>
-        public RecordingStatus Status { get; set; }
-
-        /// <summary>
-        /// Gets or sets the genre of the program.
-        /// </summary>
-        public List<string> Genres { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is repeat.
-        /// </summary>
-        /// <value><c>true</c> if this instance is repeat; otherwise, <c>false</c>.</value>
-        public bool IsRepeat { get; set; }
-
-        /// <summary>
-        /// Gets or sets the episode title.
-        /// </summary>
-        /// <value>The episode title.</value>
-        public string EpisodeTitle { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is hd.
-        /// </summary>
-        /// <value><c>true</c> if this instance is hd; otherwise, <c>false</c>.</value>
-        public bool? IsHD { get; set; }
-
-        /// <summary>
-        /// Gets or sets the audio.
-        /// </summary>
-        /// <value>The audio.</value>
-        public ProgramAudio? Audio { get; set; }
-
-        /// <summary>
-        /// Gets or sets the original air date.
-        /// </summary>
-        /// <value>The original air date.</value>
-        public DateTime? OriginalAirDate { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is movie.
-        /// </summary>
-        /// <value><c>true</c> if this instance is movie; otherwise, <c>false</c>.</value>
-        public bool IsMovie { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is sports.
-        /// </summary>
-        /// <value><c>true</c> if this instance is sports; otherwise, <c>false</c>.</value>
-        public bool IsSports { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is series.
-        /// </summary>
-        /// <value><c>true</c> if this instance is series; otherwise, <c>false</c>.</value>
-        public bool IsSeries { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is live.
-        /// </summary>
-        /// <value><c>true</c> if this instance is live; otherwise, <c>false</c>.</value>
-        public bool IsLive { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is news.
-        /// </summary>
-        /// <value><c>true</c> if this instance is news; otherwise, <c>false</c>.</value>
-        public bool IsNews { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is kids.
-        /// </summary>
-        /// <value><c>true</c> if this instance is kids; otherwise, <c>false</c>.</value>
-        public bool IsKids { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance is premiere.
-        /// </summary>
-        /// <value><c>true</c> if this instance is premiere; otherwise, <c>false</c>.</value>
-        public bool IsPremiere { get; set; }
-
-        /// <summary>
-        /// Gets or sets the official rating.
-        /// </summary>
-        /// <value>The official rating.</value>
-        public string OfficialRating { get; set; }
-
-        /// <summary>
-        /// Gets or sets the community rating.
-        /// </summary>
-        /// <value>The community rating.</value>
-        public float? CommunityRating { get; set; }
-
-        /// <summary>
-        /// Gets or sets the image path if it can be accessed directly from the file system.
-        /// </summary>
-        /// <value>The image path.</value>
-        public string ImagePath { get; set; }
-
-        /// <summary>
-        /// Gets or sets the image url if it can be downloaded.
-        /// </summary>
-        /// <value>The image URL.</value>
-        public string ImageUrl { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this instance has image.
-        /// </summary>
-        /// <value><c>null</c> if [has image] contains no value, <c>true</c> if [has image]; otherwise, <c>false</c>.</value>
-        public bool? HasImage { get; set; }
-
-        /// <summary>
-        /// Gets or sets the show identifier.
-        /// </summary>
-        /// <value>The show identifier.</value>
-        public string ShowId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the date last updated.
-        /// </summary>
-        /// <value>The date last updated.</value>
-        public DateTime DateLastUpdated { get; set; }
-    }
-}

+ 0 - 16
MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs

@@ -1,16 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-using System;
-using MediaBrowser.Model.LiveTv;
-
-namespace MediaBrowser.Controller.LiveTv
-{
-    public class RecordingStatusChangedEventArgs : EventArgs
-    {
-        public string RecordingId { get; set; }
-
-        public RecordingStatus NewStatus { get; set; }
-    }
-}

+ 0 - 2
MediaBrowser.Model/IO/IStreamHelper.cs

@@ -13,8 +13,6 @@ namespace MediaBrowser.Model.IO
 
         Task CopyToAsync(Stream source, Stream destination, int bufferSize, int emptyReadLimit, CancellationToken cancellationToken);
 
-        Task CopyToAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken);
-
         Task CopyUntilCancelled(Stream source, Stream target, int bufferSize, CancellationToken cancellationToken);
     }
 }

+ 0 - 12
MediaBrowser.Model/LiveTv/LiveTvTunerStatus.cs

@@ -1,12 +0,0 @@
-#pragma warning disable CS1591
-
-namespace MediaBrowser.Model.LiveTv
-{
-    public enum LiveTvTunerStatus
-    {
-        Available = 0,
-        Disabled = 1,
-        RecordingTv = 2,
-        LiveTv = 3
-    }
-}

+ 0 - 34
src/Jellyfin.LiveTv/Channels/ChannelManager.cs

@@ -113,15 +113,6 @@ namespace Jellyfin.LiveTv.Channels
             return channel is ISupportsDelete supportsDelete && supportsDelete.CanDelete(item);
         }
 
-        /// <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)
         {
@@ -562,18 +553,6 @@ namespace Jellyfin.LiveTv.Channels
             return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures());
         }
 
-        /// <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>
@@ -1215,19 +1194,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()
         {

+ 18 - 0
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");
+}

+ 24 - 0
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"
+            }
+        };
+    }
+}

+ 17 - 101
src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs

@@ -17,6 +17,7 @@ using System.Xml;
 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;
@@ -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;
@@ -53,6 +52,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;
@@ -79,6 +79,7 @@ namespace Jellyfin.LiveTv.EmbyTV
             IHttpClientFactory httpClientFactory,
             IServerConfigurationManager config,
             ILiveTvManager liveTvManager,
+            ITunerHostManager tunerHostManager,
             IFileSystem fileSystem,
             ILibraryManager libraryManager,
             ILibraryMonitor libraryMonitor,
@@ -96,6 +97,7 @@ namespace Jellyfin.LiveTv.EmbyTV
             _providerManager = providerManager;
             _mediaEncoder = mediaEncoder;
             _liveTvManager = (LiveTvManager)liveTvManager;
+            _tunerHostManager = tunerHostManager;
             _mediaSourceManager = mediaSourceManager;
             _streamHelper = streamHelper;
 
@@ -126,7 +128,7 @@ namespace Jellyfin.LiveTv.EmbyTV
         {
             get
             {
-                var path = GetConfiguration().RecordingPath;
+                var path = _config.GetLiveTvConfiguration().RecordingPath;
 
                 return string.IsNullOrWhiteSpace(path)
                     ? DefaultRecordingPath
@@ -189,7 +191,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))
@@ -309,7 +311,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 +511,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 +833,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,7 +934,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));
@@ -965,7 +967,7 @@ namespace Jellyfin.LiveTv.EmbyTV
                 return result;
             }
 
-            foreach (var hostInstance in _liveTvManager.TunerHosts)
+            foreach (var hostInstance in _tunerHostManager.TunerHosts)
             {
                 try
                 {
@@ -997,7 +999,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 +1023,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 +1073,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)
@@ -1596,7 +1593,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 +1774,7 @@ namespace Jellyfin.LiveTv.EmbyTV
                     program.AddGenre("News");
                 }
 
-                var config = GetConfiguration();
+                var config = _config.GetLiveTvConfiguration();
 
                 if (config.SaveRecordingNFO)
                 {
@@ -2128,11 +2125,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)
@@ -2519,7 +2511,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 +2522,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 +2533,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>();
-            }
-        }
     }
 }

+ 31 - 0
src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs

@@ -0,0 +1,31 @@
+using Jellyfin.LiveTv.Channels;
+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<ITunerHost, HdHomerunHost>();
+        services.AddSingleton<ITunerHost, M3UTunerHost>();
+    }
+}

+ 0 - 25
src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs

@@ -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"
-                }
-            };
-        }
-    }
-}

+ 16 - 83
src/Jellyfin.LiveTv/LiveTvManager.cs

@@ -12,7 +12,7 @@ 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 MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Progress;
 using MediaBrowser.Controller.Channels;
@@ -57,9 +57,9 @@ namespace Jellyfin.LiveTv
         private readonly IFileSystem _fileSystem;
         private readonly IChannelManager _channelManager;
         private readonly LiveTvDtoService _tvDtoService;
+        private readonly ITunerHostManager _tunerHostManager;
 
         private ILiveTvService[] _services = Array.Empty<ILiveTvService>();
-        private ITunerHost[] _tunerHosts = Array.Empty<ITunerHost>();
         private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>();
 
         public LiveTvManager(
@@ -74,7 +74,8 @@ namespace Jellyfin.LiveTv
             ILocalizationManager localization,
             IFileSystem fileSystem,
             IChannelManager channelManager,
-            LiveTvDtoService liveTvDtoService)
+            LiveTvDtoService liveTvDtoService,
+            ITunerHostManager tunerHostManager)
         {
             _config = config;
             _logger = logger;
@@ -88,6 +89,7 @@ namespace Jellyfin.LiveTv
             _userDataManager = userDataManager;
             _channelManager = channelManager;
             _tvDtoService = liveTvDtoService;
+            _tunerHostManager = tunerHostManager;
         }
 
         public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled;
@@ -104,30 +106,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 +148,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)
@@ -1034,7 +1009,7 @@ namespace Jellyfin.LiveTv
         {
             await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false);
 
-            await EmbyTV.EmbyTV.Current.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
+            await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
 
             var numComplete = 0;
             double progressPerService = _services.Length == 0
@@ -1302,7 +1277,7 @@ namespace Jellyfin.LiveTv
 
         private double GetGuideDays()
         {
-            var config = GetConfiguration();
+            var config = _config.GetLiveTvConfiguration();
 
             if (config.GuideDays.HasValue)
             {
@@ -2125,7 +2100,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 +2146,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 +2165,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 +2190,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 +2200,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 +2260,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 +2290,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);
         }

+ 2 - 7
src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs

@@ -2,9 +2,9 @@ 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.LiveTv;
 using MediaBrowser.Model.Tasks;
 
 namespace Jellyfin.LiveTv
@@ -38,7 +38,7 @@ namespace Jellyfin.LiveTv
         public string Category => "Live TV";
 
         /// <inheritdoc />
-        public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0;
+        public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0;
 
         /// <inheritdoc />
         public bool IsEnabled => true;
@@ -66,10 +66,5 @@ namespace Jellyfin.LiveTv
                 new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks }
             };
         }
-
-        private LiveTvOptions GetConfiguration()
-        {
-            return _config.GetConfiguration<LiveTvOptions>("livetv");
-        }
     }
 }

+ 0 - 30
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);

+ 2 - 7
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");
-        }
     }
 }

+ 10 - 173
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,

+ 0 - 16
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;

+ 174 - 0
src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs

@@ -0,0 +1,174 @@
+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 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>();
+        }
+    }
+}