From 59c2ae944ddc0b4231f4e99863cf4c2f2a16e66f Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 09:50:35 -0500 Subject: Add IGuideManager service --- MediaBrowser.Controller/LiveTv/IGuideManager.cs | 26 ++++++++++++++++++++++++ MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 6 ------ 2 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 MediaBrowser.Controller/LiveTv/IGuideManager.cs (limited to 'MediaBrowser.Controller/LiveTv') diff --git a/MediaBrowser.Controller/LiveTv/IGuideManager.cs b/MediaBrowser.Controller/LiveTv/IGuideManager.cs new file mode 100644 index 0000000000..9883b9283c --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/IGuideManager.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.LiveTv; + +namespace MediaBrowser.Controller.LiveTv; + +/// +/// Service responsible for managing the Live TV guide. +/// +public interface IGuideManager +{ + /// + /// Gets the guide information. + /// + /// The . + GuideInfo GetGuideInfo(); + + /// + /// Refresh the guide. + /// + /// The to use. + /// The to use. + /// Task representing the refresh operation. + Task RefreshGuide(IProgress progress, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 26f9fe42d3..2dbc2cf82e 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -174,12 +174,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task. Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken); - /// - /// Gets the guide information. - /// - /// GuideInfo. - GuideInfo GetGuideInfo(); - /// /// Gets the recommended programs. /// -- cgit v1.2.3 From efd024bafecd132d7b2f94839e19847411cbf273 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:02:12 -0500 Subject: Use DI for IListingsProvider --- Emby.Server.Implementations/ApplicationHost.cs | 2 +- MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 3 +-- .../Extensions/LiveTvServiceCollectionExtensions.cs | 3 +++ src/Jellyfin.LiveTv/LiveTvManager.cs | 10 +++++----- 4 files changed, 10 insertions(+), 8 deletions(-) (limited to 'MediaBrowser.Controller/LiveTv') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 5870fed761..84189f7f51 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -695,7 +695,7 @@ namespace Emby.Server.Implementations GetExports(), GetExports()); - Resolve().AddParts(GetExports(), GetExports()); + Resolve().AddParts(GetExports()); Resolve().AddParts(GetExports()); } diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 2dbc2cf82e..69daa5c208 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -71,8 +71,7 @@ namespace MediaBrowser.Controller.LiveTv /// Adds the parts. /// /// The services. - /// The listing providers. - void AddParts(IEnumerable services, IEnumerable listingProviders); + void AddParts(IEnumerable services); /// /// Gets the timer. diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index 21dab69e05..eb97ef3ee7 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Jellyfin.LiveTv.Channels; using Jellyfin.LiveTv.Guide; +using Jellyfin.LiveTv.Listings; using Jellyfin.LiveTv.TunerHosts; using Jellyfin.LiveTv.TunerHosts.HdHomerun; using MediaBrowser.Controller.Channels; @@ -29,5 +30,7 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index aa3be2048a..1595c8553e 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -47,9 +47,9 @@ namespace Jellyfin.LiveTv private readonly ILocalizationManager _localization; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; + private readonly IListingsProvider[] _listingProviders; private ILiveTvService[] _services = Array.Empty(); - private IListingsProvider[] _listingProviders = Array.Empty(); public LiveTvManager( IServerConfigurationManager config, @@ -61,7 +61,8 @@ namespace Jellyfin.LiveTv ITaskManager taskManager, ILocalizationManager localization, IChannelManager channelManager, - LiveTvDtoService liveTvDtoService) + LiveTvDtoService liveTvDtoService, + IEnumerable listingProviders) { _config = config; _logger = logger; @@ -73,6 +74,7 @@ namespace Jellyfin.LiveTv _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; + _listingProviders = listingProviders.ToArray(); } public event EventHandler> SeriesTimerCancelled; @@ -97,12 +99,10 @@ namespace Jellyfin.LiveTv } /// - public void AddParts(IEnumerable services, IEnumerable listingProviders) + public void AddParts(IEnumerable services) { _services = services.ToArray(); - _listingProviders = listingProviders.ToArray(); - foreach (var service in _services) { if (service is EmbyTV.EmbyTV embyTv) -- cgit v1.2.3 From 34269dee581b095fe63251aa0ffc1360375c989b Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:35:48 -0500 Subject: Use DI for ILiveTvService --- Emby.Server.Implementations/ApplicationHost.cs | 2 -- MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 6 ------ .../LiveTvServiceCollectionExtensions.cs | 1 + src/Jellyfin.LiveTv/LiveTvManager.cs | 24 +++++++--------------- 4 files changed, 8 insertions(+), 25 deletions(-) (limited to 'MediaBrowser.Controller/LiveTv') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 84189f7f51..d268a6ba84 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -695,8 +695,6 @@ namespace Emby.Server.Implementations GetExports(), GetExports()); - Resolve().AddParts(GetExports()); - Resolve().AddParts(GetExports()); } diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 69daa5c208..7da455b8d4 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -67,12 +67,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task. Task CancelSeriesTimer(string id); - /// - /// Adds the parts. - /// - /// The services. - void AddParts(IEnumerable services); - /// /// Gets the timer. /// diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index eb97ef3ee7..a07325ad18 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 19a71a119a..ef5283b980 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -47,10 +47,9 @@ namespace Jellyfin.LiveTv private readonly ILocalizationManager _localization; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; + private readonly ILiveTvService[] _services; private readonly IListingsProvider[] _listingProviders; - private ILiveTvService[] _services = Array.Empty(); - public LiveTvManager( IServerConfigurationManager config, ILogger logger, @@ -62,6 +61,7 @@ namespace Jellyfin.LiveTv ILocalizationManager localization, IChannelManager channelManager, LiveTvDtoService liveTvDtoService, + IEnumerable services, IEnumerable listingProviders) { _config = config; @@ -74,7 +74,12 @@ namespace Jellyfin.LiveTv _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; + _services = services.ToArray(); _listingProviders = listingProviders.ToArray(); + + var defaultService = _services.OfType().First(); + defaultService.TimerCreated += OnEmbyTvTimerCreated; + defaultService.TimerCancelled += OnEmbyTvTimerCancelled; } public event EventHandler> SeriesTimerCancelled; @@ -98,21 +103,6 @@ namespace Jellyfin.LiveTv return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); } - /// - public void AddParts(IEnumerable services) - { - _services = services.ToArray(); - - foreach (var service in _services) - { - if (service is EmbyTV.EmbyTV embyTv) - { - embyTv.TimerCreated += OnEmbyTvTimerCreated; - embyTv.TimerCancelled += OnEmbyTvTimerCancelled; - } - } - } - private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs e) { var timerId = e.Argument; -- cgit v1.2.3 From 42b052a5a619abf33ceeb4bc4aafcc1d3d52a723 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 15:18:15 -0500 Subject: Add IListingsManager service --- Jellyfin.Api/Controllers/LiveTvController.cs | 50 +-- .../Models/LiveTvDtos/ChannelMappingOptionsDto.cs | 32 -- MediaBrowser.Controller/LiveTv/IListingsManager.cs | 79 ++++ MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 31 -- .../LiveTv/TunerChannelMapping.cs | 17 - .../LiveTv/ChannelMappingOptionsDto.cs | 31 ++ MediaBrowser.Model/LiveTv/TunerChannelMapping.cs | 16 + src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 285 +------------ .../LiveTvServiceCollectionExtensions.cs | 1 + src/Jellyfin.LiveTv/Listings/ListingsManager.cs | 470 +++++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 168 +------- 11 files changed, 620 insertions(+), 560 deletions(-) delete mode 100644 Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs create mode 100644 MediaBrowser.Controller/LiveTv/IListingsManager.cs delete mode 100644 MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs create mode 100644 MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs create mode 100644 MediaBrowser.Model/LiveTv/TunerChannelMapping.cs create mode 100644 src/Jellyfin.LiveTv/Listings/ListingsManager.cs (limited to 'MediaBrowser.Controller/LiveTv') diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index da68c72c99..7f4cad951e 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -45,6 +45,7 @@ public class LiveTvController : BaseJellyfinApiController private readonly ILiveTvManager _liveTvManager; private readonly IGuideManager _guideManager; private readonly ITunerHostManager _tunerHostManager; + private readonly IListingsManager _listingsManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; @@ -59,6 +60,7 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -70,6 +72,7 @@ public class LiveTvController : BaseJellyfinApiController ILiveTvManager liveTvManager, IGuideManager guideManager, ITunerHostManager tunerHostManager, + IListingsManager listingsManager, IUserManager userManager, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, @@ -81,6 +84,7 @@ public class LiveTvController : BaseJellyfinApiController _liveTvManager = liveTvManager; _guideManager = guideManager; _tunerHostManager = tunerHostManager; + _listingsManager = listingsManager; _userManager = userManager; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; @@ -1015,7 +1019,7 @@ public class LiveTvController : BaseJellyfinApiController listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); } - return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); } /// @@ -1029,7 +1033,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DeleteListingProvider([FromQuery] string? id) { - _liveTvManager.DeleteListingsProvider(id); + _listingsManager.DeleteListingsProvider(id); return NoContent(); } @@ -1050,9 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] string? type, [FromQuery] string? location, [FromQuery] string? country) - { - return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); - } + => await _listingsManager.GetLineups(type, id, country, location).ConfigureAwait(false); /// /// Gets available countries. @@ -1083,48 +1085,20 @@ public class LiveTvController : BaseJellyfinApiController [HttpGet("ChannelMappingOptions")] [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetChannelMappingOptions([FromQuery] string? providerId) - { - var config = _configurationManager.GetConfiguration("livetv"); - - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - - var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; - - var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var mappings = listingsProviderInfo.ChannelMappings; - - return new ChannelMappingOptionsDto - { - TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), - ProviderChannels = providerChannels.Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Id - }).ToList(), - Mappings = mappings, - ProviderName = listingsProviderName - }; - } + public Task GetChannelMappingOptions([FromQuery] string? providerId) + => _listingsManager.GetChannelMappingOptions(providerId); /// /// Set channel mappings. /// - /// The set channel mapping dto. + /// The set channel mapping dto. /// Created channel mapping returned. /// An containing the created channel mapping. [HttpPost("ChannelMappings")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) - { - return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); - } + public Task SetChannelMapping([FromBody, Required] SetChannelMappingDto dto) + => _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId); /// /// Get tuner host types. diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs deleted file mode 100644 index cbc3548b10..0000000000 --- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; - -namespace Jellyfin.Api.Models.LiveTvDtos; - -/// -/// Channel mapping options dto. -/// -public class ChannelMappingOptionsDto -{ - /// - /// Gets or sets list of tuner channels. - /// - public required IReadOnlyList TunerChannels { get; set; } - - /// - /// Gets or sets list of provider channels. - /// - public required IReadOnlyList ProviderChannels { get; set; } - - /// - /// Gets or sets list of mappings. - /// - public IReadOnlyList Mappings { get; set; } = Array.Empty(); - - /// - /// Gets or sets provider name. - /// - public string? ProviderName { get; set; } -} diff --git a/MediaBrowser.Controller/LiveTv/IListingsManager.cs b/MediaBrowser.Controller/LiveTv/IListingsManager.cs new file mode 100644 index 0000000000..bbf569575a --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/IListingsManager.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; + +namespace MediaBrowser.Controller.LiveTv; + +/// +/// Service responsible for managing s and mapping +/// their channels to channels provided by s. +/// +public interface IListingsManager +{ + /// + /// Saves the listing provider. + /// + /// The listing provider information. + /// A value indicating whether to validate login. + /// A value indicating whether to validate listings.. + /// Task. + Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings); + + /// + /// Deletes the listing provider. + /// + /// The listing provider's id. + void DeleteListingsProvider(string? id); + + /// + /// Gets the lineups. + /// + /// Type of the provider. + /// The provider identifier. + /// The country. + /// The location. + /// The available lineups. + Task> GetLineups(string? providerType, string? providerId, string? country, string? location); + + /// + /// Gets the programs for a provided channel. + /// + /// The channel to retrieve programs for. + /// The earliest date to retrieve programs for. + /// The latest date to retrieve programs for. + /// The to use. + /// The available programs. + Task> GetProgramsAsync( + ChannelInfo channel, + DateTime startDateUtc, + DateTime endDateUtc, + CancellationToken cancellationToken); + + /// + /// Adds metadata from the s to the provided channels. + /// + /// The channels. + /// A value indicating whether to use the EPG channel cache. + /// The to use. + /// A task representing the metadata population. + Task AddProviderMetadata(IList channels, bool enableCache, CancellationToken cancellationToken); + + /// + /// Gets the channel mapping options for a provider. + /// + /// The id of the provider to use. + /// The channel mapping options. + Task GetChannelMappingOptions(string? providerId); + + /// + /// Sets the channel mapping. + /// + /// The id of the provider for the mapping. + /// The tuner channel number. + /// The provider channel number. + /// The updated channel mapping. + Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber); +} diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 7da455b8d4..0ac0699a37 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -36,8 +36,6 @@ namespace MediaBrowser.Controller.LiveTv /// The services. IReadOnlyList Services { get; } - IReadOnlyList ListingProviders { get; } - /// /// Gets the new timer defaults asynchronous. /// @@ -239,31 +237,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task. Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem Item, BaseItemDto ItemDto)> programs, IReadOnlyList fields, User user = null); - /// - /// Saves the listing provider. - /// - /// The information. - /// if set to true [validate login]. - /// if set to true [validate listings]. - /// Task. - Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings); - - void DeleteListingsProvider(string id); - - Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber); - - TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List providerChannels); - - /// - /// Gets the lineups. - /// - /// Type of the provider. - /// The provider identifier. - /// The country. - /// The location. - /// Task<List<NameIdPair>>. - Task> GetLineups(string providerType, string providerId, string country, string location); - /// /// Adds the channel information. /// @@ -272,10 +245,6 @@ namespace MediaBrowser.Controller.LiveTv /// The user. void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user); - Task> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken); - - Task> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken); - string GetEmbyTvActiveRecordingPath(string id); ActiveRecordingInfo GetActiveRecordingInfo(string path); diff --git a/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs b/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs deleted file mode 100644 index 1c1a4417dc..0000000000 --- a/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs +++ /dev/null @@ -1,17 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -namespace MediaBrowser.Controller.LiveTv -{ - public class TunerChannelMapping - { - public string Name { get; set; } - - public string ProviderChannelName { get; set; } - - public string ProviderChannelId { get; set; } - - public string Id { get; set; } - } -} diff --git a/MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs b/MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs new file mode 100644 index 0000000000..3f9ecc8c86 --- /dev/null +++ b/MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Model.LiveTv; + +/// +/// Channel mapping options dto. +/// +public class ChannelMappingOptionsDto +{ + /// + /// Gets or sets list of tuner channels. + /// + public required IReadOnlyList TunerChannels { get; set; } + + /// + /// Gets or sets list of provider channels. + /// + public required IReadOnlyList ProviderChannels { get; set; } + + /// + /// Gets or sets list of mappings. + /// + public IReadOnlyList Mappings { get; set; } = Array.Empty(); + + /// + /// Gets or sets provider name. + /// + public string? ProviderName { get; set; } +} diff --git a/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs b/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs new file mode 100644 index 0000000000..647e24a913 --- /dev/null +++ b/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs @@ -0,0 +1,16 @@ +#nullable disable + +#pragma warning disable CS1591 + +namespace MediaBrowser.Model.LiveTv; + +public class TunerChannelMapping +{ + public string Name { get; set; } + + public string ProviderChannelName { get; set; } + + public string ProviderChannelId { get; set; } + + public string Id { get; set; } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 39f334184b..e19d2c5911 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -61,14 +61,11 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly IMediaSourceManager _mediaSourceManager; private readonly IStreamHelper _streamHelper; private readonly LiveTvDtoService _tvDtoService; - private readonly IListingsProvider[] _listingsProviders; + private readonly IListingsManager _listingsManager; private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _epgChannels = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1); private bool _disposed; @@ -86,7 +83,7 @@ namespace Jellyfin.LiveTv.EmbyTV IProviderManager providerManager, IMediaEncoder mediaEncoder, LiveTvDtoService tvDtoService, - IEnumerable listingsProviders) + IListingsManager listingsManager) { Current = this; @@ -102,7 +99,7 @@ namespace Jellyfin.LiveTv.EmbyTV _tunerHostManager = tunerHostManager; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; - _listingsProviders = listingsProviders.ToArray(); + _listingsManager = listingsManager; _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); @@ -312,15 +309,15 @@ namespace Jellyfin.LiveTv.EmbyTV private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) { - var list = new List(); + var channels = new List(); foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { - var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); + var tunerChannels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); - list.AddRange(channels); + channels.AddRange(tunerChannels); } catch (Exception ex) { @@ -328,209 +325,9 @@ namespace Jellyfin.LiveTv.EmbyTV } } - foreach (var provider in GetListingProviders()) - { - var enabledChannels = list - .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId)) - .ToList(); - - if (enabledChannels.Count > 0) - { - try - { - await AddMetadata(provider.Item1, provider.Item2, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false); - } - catch (NotSupportedException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding metadata"); - } - } - } - - return list; - } - - private async Task AddMetadata( - IListingsProvider provider, - ListingsProviderInfo info, - IEnumerable tunerChannels, - bool enableCache, - CancellationToken cancellationToken) - { - var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); - - foreach (var tunerChannel in tunerChannels) - { - var epgChannel = GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); - - if (epgChannel is not null) - { - if (!string.IsNullOrWhiteSpace(epgChannel.Name)) - { - // tunerChannel.Name = epgChannel.Name; - } - - if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) - { - tunerChannel.ImageUrl = epgChannel.ImageUrl; - } - } - } - } - - private async Task GetEpgChannels( - IListingsProvider provider, - ListingsProviderInfo info, - bool enableCache, - CancellationToken cancellationToken) - { - if (!enableCache || !_epgChannels.TryGetValue(info.Id, out var result)) - { - var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false); - - foreach (var channel in channels) - { - _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id); - } - - result = new EpgChannelData(channels); - _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result); - } - - return result; - } - - private async Task GetEpgChannelFromTunerChannel(IListingsProvider provider, ListingsProviderInfo info, ChannelInfo tunerChannel, CancellationToken cancellationToken) - { - var epgChannels = await GetEpgChannels(provider, info, true, cancellationToken).ConfigureAwait(false); - - return GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); - } - - private static string GetMappedChannel(string channelId, NameValuePair[] mappings) - { - foreach (NameValuePair mapping in mappings) - { - if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase)) - { - return mapping.Value; - } - } - - return channelId; - } - - internal ChannelInfo GetEpgChannelFromTunerChannel(NameValuePair[] mappings, ChannelInfo tunerChannel, List epgChannels) - { - return GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(epgChannels)); - } + await _listingsManager.AddProviderMetadata(channels, enableCache, cancellationToken).ConfigureAwait(false); - private ChannelInfo GetEpgChannelFromTunerChannel(ListingsProviderInfo info, ChannelInfo tunerChannel, EpgChannelData epgChannels) - { - return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); - } - - private ChannelInfo GetEpgChannelFromTunerChannel( - NameValuePair[] mappings, - ChannelInfo tunerChannel, - EpgChannelData epgChannelData) - { - if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) - { - var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings); - - if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) - { - mappedTunerChannelId = tunerChannel.Id; - } - - var channel = epgChannelData.GetChannelById(mappedTunerChannelId); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) - { - var tunerChannelId = tunerChannel.TunerChannelId; - if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) - { - tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); - } - - var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings); - - if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) - { - mappedTunerChannelId = tunerChannelId; - } - - var channel = epgChannelData.GetChannelById(mappedTunerChannelId); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) - { - var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings); - - if (string.IsNullOrWhiteSpace(tunerChannelNumber)) - { - tunerChannelNumber = tunerChannel.Number; - } - - var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) - { - var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name); - - var channel = epgChannelData.GetChannelByName(normalizedName); - - if (channel is not null) - { - return channel; - } - } - - return null; - } - - public async Task> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) - { - var list = new List(); - - foreach (var hostInstance in _tunerHostManager.TunerHosts) - { - try - { - var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); - - list.AddRange(channels); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channels"); - } - } - - return list - .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId)) - .ToList(); + return channels; } public Task> GetChannelsAsync(CancellationToken cancellationToken) @@ -877,75 +674,13 @@ namespace Jellyfin.LiveTv.EmbyTV return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); } - private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) - { - if (info.EnableAllTuners) - { - return true; - } - - if (string.IsNullOrWhiteSpace(tunerHostId)) - { - throw new ArgumentNullException(nameof(tunerHostId)); - } - - return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase); - } - public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) { var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); - foreach (var provider in GetListingProviders()) - { - if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId)) - { - _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - continue; - } - - _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - - var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); - - if (epgChannel is null) - { - _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - continue; - } - - List programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) - .ConfigureAwait(false)).ToList(); - - // Replace the value that came from the provider with a normalized value - foreach (var program in programs) - { - program.ChannelId = channelId; - - program.Id += "_" + channelId; - } - - if (programs.Count > 0) - { - return programs; - } - } - - return Enumerable.Empty(); - } - - private List> GetListingProviders() - { - return _config.GetLiveTvConfiguration().ListingProviders - .Select(i => - { - var provider = _listingsProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - return provider is null ? null : new Tuple(provider, i); - }) - .Where(i => i is not null) - .ToList(); + return await _listingsManager.GetProgramsAsync(channel, startDateUtc, endDateUtc, cancellationToken) + .ConfigureAwait(false); } public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index a07325ad18..e4800a0312 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs new file mode 100644 index 0000000000..113979257d --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Concurrent; +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.EmbyTV; +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.Listings; + +/// +public class ListingsManager : IListingsManager +{ + private readonly ILogger _logger; + private readonly IConfigurationManager _config; + private readonly ITaskManager _taskManager; + private readonly ITunerHostManager _tunerHostManager; + private readonly IListingsProvider[] _listingsProviders; + + private readonly ConcurrentDictionary _epgChannels = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + /// The . + public ListingsManager( + ILogger logger, + IConfigurationManager config, + ITaskManager taskManager, + ITunerHostManager tunerHostManager, + IEnumerable listingsProviders) + { + _logger = logger; + _config = config; + _taskManager = taskManager; + _tunerHostManager = tunerHostManager; + _listingsProviders = listingsProviders.ToArray(); + } + + /// + public async Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + ArgumentNullException.ThrowIfNull(info); + + // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider + // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider + info = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(info))!; + + var provider = GetProvider(info.Type); + await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false); + + var config = _config.GetLiveTvConfiguration(); + + var list = config.ListingProviders.ToList(); + int 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.ListingProviders = list.ToArray(); + } + else + { + config.ListingProviders[index] = info; + } + + _config.SaveConfiguration("livetv", config); + _taskManager.CancelIfRunningAndQueue(); + + return info; + } + + /// + public void DeleteListingsProvider(string? id) + { + var config = _config.GetLiveTvConfiguration(); + + config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); + + _config.SaveConfiguration("livetv", config); + _taskManager.CancelIfRunningAndQueue(); + } + + /// + public Task> GetLineups(string? providerType, string? providerId, string? country, string? location) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return GetProvider(providerType).GetLineups(null, country, location); + } + + var info = _config.GetLiveTvConfiguration().ListingProviders + .FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase)) + ?? throw new ResourceNotFoundException(); + + return GetProvider(info.Type).GetLineups(info, country, location); + } + + /// + public async Task> GetProgramsAsync( + ChannelInfo channel, + DateTime startDateUtc, + DateTime endDateUtc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(channel); + + foreach (var (provider, providerInfo) in GetListingProviders()) + { + if (!IsListingProviderEnabledForTuner(providerInfo, channel.TunerHostId)) + { + _logger.LogDebug( + "Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", + channel.Number, + channel.Name, + provider.Name, + providerInfo.ListingsId ?? string.Empty); + continue; + } + + _logger.LogDebug( + "Getting programs for channel {0}-{1} from {2}-{3}", + channel.Number, + channel.Name, + provider.Name, + providerInfo.ListingsId ?? string.Empty); + + var epgChannels = await GetEpgChannels(provider, providerInfo, true, cancellationToken).ConfigureAwait(false); + + var epgChannel = GetEpgChannelFromTunerChannel(providerInfo.ChannelMappings, channel, epgChannels); + if (epgChannel is null) + { + _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Name, providerInfo.ListingsId ?? string.Empty); + continue; + } + + var programs = (await provider + .GetProgramsAsync(providerInfo, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken).ConfigureAwait(false)) + .ToList(); + + // Replace the value that came from the provider with a normalized value + foreach (var program in programs) + { + program.ChannelId = channel.Id; + program.Id += "_" + channel.Id; + } + + if (programs.Count > 0) + { + return programs; + } + } + + return Enumerable.Empty(); + } + + /// + public async Task AddProviderMetadata(IList channels, bool enableCache, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(channels); + + foreach (var (provider, providerInfo) in GetListingProviders()) + { + var enabledChannels = channels + .Where(i => IsListingProviderEnabledForTuner(providerInfo, i.TunerHostId)) + .ToList(); + + if (enabledChannels.Count == 0) + { + continue; + } + + try + { + await AddMetadata(provider, providerInfo, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false); + } + catch (NotSupportedException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding metadata"); + } + } + } + + /// + public async Task GetChannelMappingOptions(string? providerId) + { + var listingsProviderInfo = _config.GetLiveTvConfiguration().ListingProviders + .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase)); + + var provider = GetProvider(listingsProviderInfo.Type); + + var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None) + .ConfigureAwait(false); + + var providerChannels = await provider.GetChannels(listingsProviderInfo, default) + .ConfigureAwait(false); + + var mappings = listingsProviderInfo.ChannelMappings; + + return new ChannelMappingOptionsDto + { + TunerChannels = tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), + ProviderChannels = providerChannels.Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Id + }).ToList(), + Mappings = mappings, + ProviderName = provider.Name + }; + } + + /// + public async Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) + { + var config = _config.GetLiveTvConfiguration(); + + var listingsProviderInfo = config.ListingProviders + .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase)); + + listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings + .Where(pair => !string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); + + if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) + { + var list = listingsProviderInfo.ChannelMappings.ToList(); + list.Add(new NameValuePair + { + Name = tunerChannelNumber, + Value = providerChannelNumber + }); + listingsProviderInfo.ChannelMappings = list.ToArray(); + } + + _config.SaveConfiguration("livetv", config); + + var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None) + .ConfigureAwait(false); + + var providerChannels = await GetProvider(listingsProviderInfo.Type).GetChannels(listingsProviderInfo, default) + .ConfigureAwait(false); + + var tunerChannelMappings = tunerChannels + .Select(i => GetTunerChannelMapping(i, listingsProviderInfo.ChannelMappings, providerChannels)).ToList(); + + _taskManager.CancelIfRunningAndQueue(); + + return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)); + } + + private List> GetListingProviders() + => _config.GetLiveTvConfiguration().ListingProviders + .Select(i => + { + var provider = _listingsProviders + .FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + return provider is null ? null : new Tuple(provider, i); + }) + .Where(i => i is not null) + .ToList()!; // Already filtered out null + + private async Task AddMetadata( + IListingsProvider provider, + ListingsProviderInfo info, + IEnumerable tunerChannels, + bool enableCache, + CancellationToken cancellationToken) + { + var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); + + foreach (var tunerChannel in tunerChannels) + { + var epgChannel = GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); + if (epgChannel is null) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) + { + tunerChannel.ImageUrl = epgChannel.ImageUrl; + } + } + } + + private static bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) + { + if (info.EnableAllTuners) + { + return true; + } + + ArgumentException.ThrowIfNullOrWhiteSpace(tunerHostId); + + return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase); + } + + private static string GetMappedChannel(string channelId, NameValuePair[] mappings) + { + foreach (NameValuePair mapping in mappings) + { + if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase)) + { + return mapping.Value; + } + } + + return channelId; + } + + private async Task GetEpgChannels( + IListingsProvider provider, + ListingsProviderInfo info, + bool enableCache, + CancellationToken cancellationToken) + { + if (enableCache && _epgChannels.TryGetValue(info.Id, out var result)) + { + return result; + } + + var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false); + foreach (var channel in channels) + { + _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id); + } + + result = new EpgChannelData(channels); + _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result); + + return result; + } + + private static ChannelInfo? GetEpgChannelFromTunerChannel( + NameValuePair[] mappings, + ChannelInfo tunerChannel, + EpgChannelData epgChannelData) + { + if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) + { + var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings); + if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) + { + mappedTunerChannelId = tunerChannel.Id; + } + + var channel = epgChannelData.GetChannelById(mappedTunerChannelId); + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) + { + var tunerChannelId = tunerChannel.TunerChannelId; + if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) + { + tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); + } + + var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings); + if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) + { + mappedTunerChannelId = tunerChannelId; + } + + var channel = epgChannelData.GetChannelById(mappedTunerChannelId); + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) + { + var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings); + if (string.IsNullOrWhiteSpace(tunerChannelNumber)) + { + tunerChannelNumber = tunerChannel.Number; + } + + var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber); + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) + { + var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name); + + var channel = epgChannelData.GetChannelByName(normalizedName); + if (channel is not null) + { + return channel; + } + } + + return null; + } + + private static TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, IList providerChannels) + { + var result = new TunerChannelMapping + { + Name = tunerChannel.Name, + Id = tunerChannel.Id + }; + + if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) + { + result.Name = tunerChannel.Number + " " + result.Name; + } + + var providerChannel = GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(providerChannels)); + if (providerChannel is not null) + { + result.ProviderChannelName = providerChannel.Name; + result.ProviderChannelId = providerChannel.Id; + } + + return result; + } + + private async Task> GetChannelsForListingsProvider(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var channels = new List(); + foreach (var hostInstance in _tunerHostManager.TunerHosts) + { + try + { + var tunerChannels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); + + channels.AddRange(tunerChannels.Where(channel => IsListingProviderEnabledForTuner(info, channel.TunerHostId))); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channels"); + } + } + + return channels; + } + + private IListingsProvider GetProvider(string? providerType) + => _listingsProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase)) + ?? throw new ResourceNotFoundException($"Couldn't find provider of type {providerType}"); +} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index ef5283b980..1b69fd7fdd 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -6,14 +6,12 @@ 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.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.LiveTv.Configuration; -using Jellyfin.LiveTv.Guide; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -27,7 +25,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Jellyfin.LiveTv @@ -43,12 +40,10 @@ namespace Jellyfin.LiveTv private readonly IDtoService _dtoService; private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; - private readonly ITaskManager _taskManager; private readonly ILocalizationManager _localization; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; private readonly ILiveTvService[] _services; - private readonly IListingsProvider[] _listingProviders; public LiveTvManager( IServerConfigurationManager config, @@ -57,25 +52,21 @@ namespace Jellyfin.LiveTv IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, - ITaskManager taskManager, ILocalizationManager localization, IChannelManager channelManager, LiveTvDtoService liveTvDtoService, - IEnumerable services, - IEnumerable listingProviders) + IEnumerable services) { _config = config; _logger = logger; _userManager = userManager; _libraryManager = libraryManager; - _taskManager = taskManager; _localization = localization; _dtoService = dtoService; _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; _services = services.ToArray(); - _listingProviders = listingProviders.ToArray(); var defaultService = _services.OfType().First(); defaultService.TimerCreated += OnEmbyTvTimerCreated; @@ -96,8 +87,6 @@ namespace Jellyfin.LiveTv /// The services. public IReadOnlyList Services => _services; - public IReadOnlyList ListingProviders => _listingProviders; - public string GetEmbyTvActiveRecordingPath(string id) { return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); @@ -1465,161 +1454,6 @@ namespace Jellyfin.LiveTv return _libraryManager.GetNamedView(name, CollectionType.livetv, name); } - public async Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings) - { - // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider - // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider - info = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(info)); - - var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ResourceNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "Couldn't find provider of type: '{0}'", - info.Type)); - } - - await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false); - - var config = _config.GetLiveTvConfiguration(); - - var list = config.ListingProviders.ToList(); - int 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.ListingProviders = list.ToArray(); - } - else - { - config.ListingProviders[index] = info; - } - - _config.SaveConfiguration("livetv", config); - - _taskManager.CancelIfRunningAndQueue(); - - return info; - } - - public void DeleteListingsProvider(string id) - { - var config = _config.GetLiveTvConfiguration(); - - config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); - - _config.SaveConfiguration("livetv", config); - _taskManager.CancelIfRunningAndQueue(); - } - - public async Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) - { - 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(); - - if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) - { - var list = listingsProviderInfo.ChannelMappings.ToList(); - list.Add(new NameValuePair - { - Name = tunerChannelNumber, - Value = providerChannelNumber - }); - listingsProviderInfo.ChannelMappings = list.ToArray(); - } - - _config.SaveConfiguration("livetv", config); - - var tunerChannels = await GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var providerChannels = await GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var mappings = listingsProviderInfo.ChannelMappings; - - var tunerChannelMappings = - tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(); - - _taskManager.CancelIfRunningAndQueue(); - - return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)); - } - - public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List providerChannels) - { - var result = new TunerChannelMapping - { - Name = tunerChannel.Name, - Id = tunerChannel.Id - }; - - if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) - { - result.Name = tunerChannel.Number + " " + result.Name; - } - - var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, providerChannels); - - if (providerChannel is not null) - { - result.ProviderChannelName = providerChannel.Name; - result.ProviderChannelId = providerChannel.Id; - } - - return result; - } - - public Task> GetLineups(string providerType, string providerId, string country, string location) - { - var config = _config.GetLiveTvConfiguration(); - - if (string.IsNullOrWhiteSpace(providerId)) - { - var provider = _listingProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ResourceNotFoundException(); - } - - return provider.GetLineups(null, country, location); - } - else - { - var info = config.ListingProviders.FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase)); - - var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ResourceNotFoundException(); - } - - return provider.GetLineups(info, country, location); - } - } - - public Task> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken) - { - var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); - return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken); - } - - public Task> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken) - { - var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); - var provider = _listingProviders.First(i => string.Equals(i.Type, info.Type, StringComparison.OrdinalIgnoreCase)); - return provider.GetChannels(info, cancellationToken); - } - /// public Task GetRecordingFoldersAsync(User user) => GetRecordingFoldersAsync(user, false); -- cgit v1.2.3 From 0370167b8d1a8c7616d5bc15d823c3c187aae2cc Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 9 Feb 2024 13:46:28 -0500 Subject: Add IRecordingsManager service --- Emby.Server.Implementations/ApplicationHost.cs | 2 +- Emby.Server.Implementations/Dto/DtoService.cs | 8 +- Jellyfin.Api/Controllers/LiveTvController.cs | 7 +- MediaBrowser.Controller/Entities/Video.cs | 4 +- MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 4 - .../LiveTv/IRecordingsManager.cs | 55 ++ src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 899 +-------------------- src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs | 20 +- .../LiveTvServiceCollectionExtensions.cs | 2 + src/Jellyfin.LiveTv/Guide/GuideManager.cs | 6 +- src/Jellyfin.LiveTv/LiveTvManager.cs | 21 +- src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs | 6 +- .../Recordings/RecordingsManager.cs | 849 +++++++++++++++++++ .../MediaInfo/AudioResolverTests.cs | 2 +- .../MediaInfo/MediaInfoResolverTests.cs | 2 +- .../MediaInfo/SubtitleResolverTests.cs | 2 +- 16 files changed, 985 insertions(+), 904 deletions(-) create mode 100644 MediaBrowser.Controller/LiveTv/IRecordingsManager.cs create mode 100644 src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs (limited to 'MediaBrowser.Controller/LiveTv') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 550c16b4c4..745753440d 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -630,7 +630,7 @@ namespace Emby.Server.Implementations BaseItem.FileSystem = Resolve(); BaseItem.UserDataManager = Resolve(); BaseItem.ChannelManager = Resolve(); - Video.LiveTvManager = Resolve(); + Video.RecordingsManager = Resolve(); Folder.UserViewManager = Resolve(); UserView.TVSeriesManager = Resolve(); UserView.CollectionManager = Resolve(); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index d0d5bb81c1..d372277e0a 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -47,6 +47,7 @@ namespace Emby.Server.Implementations.Dto private readonly IImageProcessor _imageProcessor; private readonly IProviderManager _providerManager; + private readonly IRecordingsManager _recordingsManager; private readonly IApplicationHost _appHost; private readonly IMediaSourceManager _mediaSourceManager; @@ -62,6 +63,7 @@ namespace Emby.Server.Implementations.Dto IItemRepository itemRepo, IImageProcessor imageProcessor, IProviderManager providerManager, + IRecordingsManager recordingsManager, IApplicationHost appHost, IMediaSourceManager mediaSourceManager, Lazy livetvManagerFactory, @@ -74,6 +76,7 @@ namespace Emby.Server.Implementations.Dto _itemRepo = itemRepo; _imageProcessor = imageProcessor; _providerManager = providerManager; + _recordingsManager = recordingsManager; _appHost = appHost; _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; @@ -256,8 +259,7 @@ namespace Emby.Server.Implementations.Dto dto.Etag = item.GetEtag(user); } - var liveTvManager = LivetvManager; - var activeRecording = liveTvManager.GetActiveRecordingInfo(item.Path); + var activeRecording = _recordingsManager.GetActiveRecordingInfo(item.Path); if (activeRecording is not null) { dto.Type = BaseItemKind.Recording; @@ -270,7 +272,7 @@ namespace Emby.Server.Implementations.Dto dto.Name = dto.SeriesName; } - liveTvManager.AddInfoToRecordingDto(item, dto, activeRecording, user); + LivetvManager.AddInfoToRecordingDto(item, dto, activeRecording, user); } return dto; diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 7f4cad951e..78dd7a71cb 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -46,6 +46,7 @@ public class LiveTvController : BaseJellyfinApiController private readonly IGuideManager _guideManager; private readonly ITunerHostManager _tunerHostManager; private readonly IListingsManager _listingsManager; + private readonly IRecordingsManager _recordingsManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; @@ -61,6 +62,7 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -73,6 +75,7 @@ public class LiveTvController : BaseJellyfinApiController IGuideManager guideManager, ITunerHostManager tunerHostManager, IListingsManager listingsManager, + IRecordingsManager recordingsManager, IUserManager userManager, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, @@ -85,6 +88,7 @@ public class LiveTvController : BaseJellyfinApiController _guideManager = guideManager; _tunerHostManager = tunerHostManager; _listingsManager = listingsManager; + _recordingsManager = recordingsManager; _userManager = userManager; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; @@ -1140,8 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesVideoFile] public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) { - var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); - + var path = _recordingsManager.GetActiveRecordingPath(recordingId); if (string.IsNullOrWhiteSpace(path)) { return NotFound(); diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 5adadec390..04f47b729d 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -171,7 +171,7 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0; - public static ILiveTvManager LiveTvManager { get; set; } + public static IRecordingsManager RecordingsManager { get; set; } [JsonIgnore] public override SourceType SourceType @@ -334,7 +334,7 @@ namespace MediaBrowser.Controller.Entities protected override bool IsActiveRecording() { - return LiveTvManager.GetActiveRecordingInfo(Path) is not null; + return RecordingsManager.GetActiveRecordingInfo(Path) is not null; } public override bool CanDelete() diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 0ac0699a37..ed08cdc476 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -245,10 +245,6 @@ namespace MediaBrowser.Controller.LiveTv /// The user. void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user); - string GetEmbyTvActiveRecordingPath(string id); - - ActiveRecordingInfo GetActiveRecordingInfo(string path); - void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null); Task GetRecordingFoldersAsync(User user); diff --git a/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs b/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs new file mode 100644 index 0000000000..b918e2931b --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.LiveTv; + +/// +/// Service responsible for managing LiveTV recordings. +/// +public interface IRecordingsManager +{ + /// + /// Gets the path for the provided timer id. + /// + /// The timer id. + /// The recording path, or null if none exists. + string? GetActiveRecordingPath(string id); + + /// + /// Gets the information for an active recording. + /// + /// The recording path. + /// The , or null if none exists. + ActiveRecordingInfo? GetActiveRecordingInfo(string path); + + /// + /// Gets the recording folders. + /// + /// The for each recording folder. + IEnumerable GetRecordingFolders(); + + /// + /// Ensures that the recording folders all exist, and removes unused folders. + /// + /// Task. + Task CreateRecordingFolders(); + + /// + /// Cancels the recording with the provided timer id, if one is active. + /// + /// The timer id. + /// The timer. + void CancelRecording(string timerId, TimerInfo? timer); + + /// + /// Records a stream. + /// + /// The recording info. + /// The channel associated with the recording timer. + /// The time to stop recording. + /// Task representing the recording process. + Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate); +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index d1688dfd9b..06a0ea4e9d 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -3,264 +3,77 @@ #pragma warning disable CS1591 using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; -using Jellyfin.LiveTv.IO; -using Jellyfin.LiveTv.Recordings; using Jellyfin.LiveTv.Timers; -using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; namespace Jellyfin.LiveTv.EmbyTV { - public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable + public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds { + public const string ServiceName = "Emby"; + private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; private readonly ITunerHostManager _tunerHostManager; - private readonly IFileSystem _fileSystem; - private readonly ILibraryMonitor _libraryMonitor; - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IStreamHelper _streamHelper; private readonly IListingsManager _listingsManager; + private readonly IRecordingsManager _recordingsManager; + private readonly ILibraryManager _libraryManager; private readonly LiveTvDtoService _tvDtoService; private readonly TimerManager _timerManager; - private readonly ItemDataProvider _seriesTimerManager; - private readonly RecordingsMetadataManager _recordingsMetadataManager; - - private readonly ConcurrentDictionary _activeRecordings = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1); - - private bool _disposed; + private readonly SeriesTimerManager _seriesTimerManager; public EmbyTV( - IStreamHelper streamHelper, - IMediaSourceManager mediaSourceManager, ILogger logger, - IHttpClientFactory httpClientFactory, IServerConfigurationManager config, ITunerHostManager tunerHostManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor, - IProviderManager providerManager, - IMediaEncoder mediaEncoder, IListingsManager listingsManager, + IRecordingsManager recordingsManager, + ILibraryManager libraryManager, LiveTvDtoService tvDtoService, TimerManager timerManager, - SeriesTimerManager seriesTimerManager, - RecordingsMetadataManager recordingsMetadataManager) + SeriesTimerManager seriesTimerManager) { - Current = this; - _logger = logger; - _httpClientFactory = httpClientFactory; _config = config; - _fileSystem = fileSystem; _libraryManager = libraryManager; - _libraryMonitor = libraryMonitor; - _providerManager = providerManager; - _mediaEncoder = mediaEncoder; _tunerHostManager = tunerHostManager; - _mediaSourceManager = mediaSourceManager; - _streamHelper = streamHelper; _listingsManager = listingsManager; + _recordingsManager = recordingsManager; _tvDtoService = tvDtoService; _timerManager = timerManager; _seriesTimerManager = seriesTimerManager; - _recordingsMetadataManager = recordingsMetadataManager; _timerManager.TimerFired += OnTimerManagerTimerFired; - _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; } public event EventHandler> TimerCreated; public event EventHandler> TimerCancelled; - public static EmbyTV Current { get; private set; } - /// - public string Name => "Emby"; - - public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); + public string Name => ServiceName; /// public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; - private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); - - private string RecordingPath - { - get - { - var path = _config.GetLiveTvConfiguration().RecordingPath; - - return string.IsNullOrWhiteSpace(path) - ? DefaultRecordingPath - : path; - } - } - - private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) - { - if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) - { - await CreateRecordingFolders().ConfigureAwait(false); - } - } - - public Task Start() - { - _timerManager.RestartTimers(); - - return CreateRecordingFolders(); - } - - internal async Task CreateRecordingFolders() - { - try - { - var recordingFolders = GetRecordingFolders().ToArray(); - var virtualFolders = _libraryManager.GetVirtualFolders(); - - var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); - - var pathsAdded = new List(); - - foreach (var recordingFolder in recordingFolders) - { - var pathsToCreate = recordingFolder.Locations - .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i))) - .ToList(); - - if (pathsToCreate.Count == 0) - { - continue; - } - - var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); - - var libraryOptions = new LibraryOptions - { - PathInfos = mediaPathInfos - }; - try - { - await _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating virtual folder"); - } - - pathsAdded.AddRange(pathsToCreate); - } - - var config = _config.GetLiveTvConfiguration(); - - var pathsToRemove = config.MediaLocationsCreated - .Except(recordingFolders.SelectMany(i => i.Locations)) - .ToList(); - - if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) - { - pathsAdded.InsertRange(0, config.MediaLocationsCreated); - config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - _config.SaveConfiguration("livetv", config); - } - - foreach (var path in pathsToRemove) - { - await RemovePathFromLibraryAsync(path).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating recording folders"); - } - } - - private async Task RemovePathFromLibraryAsync(string path) - { - _logger.LogDebug("Removing path from library: {0}", path); - - var requiresRefresh = false; - var virtualFolders = _libraryManager.GetVirtualFolders(); - - foreach (var virtualFolder in virtualFolders) - { - if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (virtualFolder.Locations.Length == 1) - { - // remove entire virtual folder - try - { - await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error removing virtual folder"); - } - } - else - { - try - { - _libraryManager.RemoveMediaPath(virtualFolder.Name, path); - requiresRefresh = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error removing media path"); - } - } - } - - if (requiresRefresh) - { - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); - } - } - public async Task RefreshSeriesTimers(CancellationToken cancellationToken) { var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); @@ -279,9 +92,9 @@ namespace Jellyfin.LiveTv.EmbyTV foreach (var timer in timers) { - if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id)) + if (DateTime.UtcNow > timer.EndDate && _recordingsManager.GetActiveRecordingPath(timer.Id) is null) { - OnTimerOutOfDate(timer); + _timerManager.Delete(timer); continue; } @@ -293,7 +106,7 @@ namespace Jellyfin.LiveTv.EmbyTV var program = GetProgramInfoFromCache(timer); if (program is null) { - OnTimerOutOfDate(timer); + _timerManager.Delete(timer); continue; } @@ -302,11 +115,6 @@ namespace Jellyfin.LiveTv.EmbyTV } } - private void OnTimerOutOfDate(TimerInfo timer) - { - _timerManager.Delete(timer); - } - private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) { var channels = new List(); @@ -384,11 +192,7 @@ namespace Jellyfin.LiveTv.EmbyTV } } - if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) - { - activeRecordingInfo.Timer = timer; - activeRecordingInfo.CancellationTokenSource.Cancel(); - } + _recordingsManager.CancelRecording(timerId, timer); } public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) @@ -544,7 +348,7 @@ namespace Jellyfin.LiveTv.EmbyTV } // Only update if not currently active - if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _)) + if (_recordingsManager.GetActiveRecordingPath(updatedTimer.Id) is null) { existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; @@ -584,40 +388,6 @@ namespace Jellyfin.LiveTv.EmbyTV existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; } - public string GetActiveRecordingPath(string id) - { - if (_activeRecordings.TryGetValue(id, out var info)) - { - return info.Path; - } - - return null; - } - - public ActiveRecordingInfo GetActiveRecordingInfo(string path) - { - if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) - { - return null; - } - - foreach (var (_, recordingInfo) in _activeRecordings) - { - if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) - { - var timer = recordingInfo.Timer; - if (timer.Status != RecordingStatus.InProgress) - { - return null; - } - - return recordingInfo; - } - } - - return null; - } - public Task> GetTimersAsync(CancellationToken cancellationToken) { var excludeStatues = new List @@ -775,11 +545,10 @@ namespace Jellyfin.LiveTv.EmbyTV try { var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); - if (recordingEndDate <= DateTime.UtcNow) { _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); - OnTimerOutOfDate(timer); + _timerManager.Delete(timer); return; } @@ -790,133 +559,39 @@ namespace Jellyfin.LiveTv.EmbyTV Id = timer.Id }; - if (!_activeRecordings.ContainsKey(timer.Id)) - { - await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false); - } - else + if (_recordingsManager.GetActiveRecordingPath(timer.Id) is not null) { _logger.LogInformation("Skipping RecordStream because it's already in progress."); - } - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording stream"); - } - } - - private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath) - { - var recordPath = RecordingPath; - var config = _config.GetLiveTvConfiguration(); - seriesPath = null; - - if (timer.IsProgramSeries) - { - var customRecordingPath = config.SeriesRecordingPath; - var allowSubfolder = true; - if (!string.IsNullOrWhiteSpace(customRecordingPath)) - { - allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); - recordPath = customRecordingPath; - } - - if (allowSubfolder && config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Series"); - } - - // trim trailing period from the folder name - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); - - if (metadata is not null && metadata.ProductionYear.HasValue) - { - folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; - } - - // Can't use the year here in the folder name because it is the year of the episode, not the series. - recordPath = Path.Combine(recordPath, folderName); - - seriesPath = recordPath; - - if (timer.SeasonNumber.HasValue) - { - folderName = string.Format( - CultureInfo.InvariantCulture, - "Season {0}", - timer.SeasonNumber.Value); - recordPath = Path.Combine(recordPath, folderName); - } - } - else if (timer.IsMovie) - { - var customRecordingPath = config.MovieRecordingPath; - var allowSubfolder = true; - if (!string.IsNullOrWhiteSpace(customRecordingPath)) - { - allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); - recordPath = customRecordingPath; - } - - if (allowSubfolder && config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Movies"); + return; } - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); - if (timer.ProductionYear.HasValue) + LiveTvProgram programInfo = null; + if (!string.IsNullOrWhiteSpace(timer.ProgramId)) { - folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + programInfo = GetProgramInfoFromCache(timer); } - // trim trailing period from the folder name - folderName = folderName.TrimEnd('.').Trim(); - - recordPath = Path.Combine(recordPath, folderName); - } - else if (timer.IsKids) - { - if (config.EnableRecordingSubfolders) + if (programInfo is null) { - recordPath = Path.Combine(recordPath, "Kids"); + _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); + programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); } - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); - if (timer.ProductionYear.HasValue) + if (programInfo is not null) { - folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + CopyProgramInfoToTimerInfo(programInfo, timer); } - // trim trailing period from the folder name - folderName = folderName.TrimEnd('.').Trim(); - - recordPath = Path.Combine(recordPath, folderName); + await _recordingsManager.RecordStream(activeRecordingInfo, GetLiveTvChannel(timer), recordingEndDate) + .ConfigureAwait(false); } - else if (timer.IsSports) + catch (OperationCanceledException) { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Sports"); - } - - recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); } - else + catch (Exception ex) { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Other"); - } - - recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + _logger.LogError(ex, "Error recording stream"); } - - var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; - - return Path.Combine(recordPath, recordingFileName); } private BaseItem GetLiveTvChannel(TimerInfo timer) @@ -925,458 +600,6 @@ namespace Jellyfin.LiveTv.EmbyTV return _libraryManager.GetItemById(internalChannelId); } - private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) - { - ArgumentNullException.ThrowIfNull(timer); - - LiveTvProgram programInfo = null; - - if (!string.IsNullOrWhiteSpace(timer.ProgramId)) - { - programInfo = GetProgramInfoFromCache(timer); - } - - if (programInfo is null) - { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); - programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); - } - - if (programInfo is not null) - { - CopyProgramInfoToTimerInfo(programInfo, timer); - } - - var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); - var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); - - var channelItem = GetLiveTvChannel(timer); - - string liveStreamId = null; - RecordingStatus recordingStatus; - try - { - var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); - - var mediaStreamInfo = allMediaSources[0]; - IDirectStreamProvider directStreamProvider = null; - - if (mediaStreamInfo.RequiresOpening) - { - var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( - new LiveStreamRequest - { - ItemId = channelItem.Id, - OpenToken = mediaStreamInfo.OpenToken - }, - CancellationToken.None).ConfigureAwait(false); - - mediaStreamInfo = liveStreamResponse.Item1.MediaSource; - liveStreamId = mediaStreamInfo.LiveStreamId; - directStreamProvider = liveStreamResponse.Item2; - } - - using var recorder = GetRecorder(mediaStreamInfo); - - recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); - recordPath = EnsureFileUnique(recordPath, timer.Id); - - _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); - - var duration = recordingEndDate - DateTime.UtcNow; - - _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); - - _logger.LogInformation("Writing file to: {Path}", recordPath); - - Action onStarted = async () => - { - activeRecordingInfo.Path = recordPath; - - _activeRecordings.TryAdd(timer.Id, activeRecordingInfo); - - timer.Status = RecordingStatus.InProgress; - _timerManager.AddOrUpdate(timer, false); - - await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); - - await CreateRecordingFolders().ConfigureAwait(false); - - TriggerRefresh(recordPath); - await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); - }; - - await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); - - recordingStatus = RecordingStatus.Completed; - _logger.LogInformation("Recording completed: {RecordPath}", recordPath); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Recording stopped: {RecordPath}", recordPath); - recordingStatus = RecordingStatus.Completed; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording to {RecordPath}", recordPath); - recordingStatus = RecordingStatus.Error; - } - - if (!string.IsNullOrWhiteSpace(liveStreamId)) - { - try - { - await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream"); - } - } - - DeleteFileIfEmpty(recordPath); - - TriggerRefresh(recordPath); - _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false); - - _activeRecordings.TryRemove(timer.Id, out _); - - if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) - { - const int RetryIntervalSeconds = 60; - _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); - - timer.Status = RecordingStatus.New; - timer.PrePaddingSeconds = 0; - timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); - timer.RetryCount++; - _timerManager.AddOrUpdate(timer); - } - else if (File.Exists(recordPath)) - { - timer.RecordingPath = recordPath; - timer.Status = RecordingStatus.Completed; - _timerManager.AddOrUpdate(timer, false); - OnSuccessfulRecording(timer, recordPath); - } - else - { - _timerManager.Delete(timer); - } - } - - private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) - { - if (timer.IsSeries) - { - if (timer.SeriesProviderIds.Count == 0) - { - return null; - } - - var query = new RemoteSearchQuery() - { - SearchInfo = new SeriesInfo - { - ProviderIds = timer.SeriesProviderIds, - Name = timer.Name, - MetadataCountryCode = _config.Configuration.MetadataCountryCode, - MetadataLanguage = _config.Configuration.PreferredMetadataLanguage - } - }; - - var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); - - return results.FirstOrDefault(); - } - - return null; - } - - private void DeleteFileIfEmpty(string path) - { - var file = _fileSystem.GetFileInfo(path); - - if (file.Exists && file.Length == 0) - { - try - { - _fileSystem.DeleteFile(path); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); - } - } - } - - private void TriggerRefresh(string path) - { - _logger.LogInformation("Triggering refresh on {Path}", path); - - var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); - - if (item is not null) - { - _logger.LogInformation("Refreshing recording parent {Path}", item.Path); - - _providerManager.QueueRefresh( - item.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - RefreshPaths = new string[] - { - path, - Path.GetDirectoryName(path), - Path.GetDirectoryName(Path.GetDirectoryName(path)) - } - }, - RefreshPriority.High); - } - } - - private BaseItem GetAffectedBaseItem(string path) - { - BaseItem item = null; - - var parentPath = Path.GetDirectoryName(path); - - while (item is null && !string.IsNullOrEmpty(path)) - { - item = _libraryManager.FindByPath(path, null); - - path = Path.GetDirectoryName(path); - } - - if (item is not null) - { - if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) - { - var parentItem = item.GetParent(); - if (parentItem is not null && parentItem is not AggregateFolder) - { - item = parentItem; - } - } - } - - return item; - } - - private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath) - { - if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) - { - return; - } - - if (string.IsNullOrWhiteSpace(seriesPath)) - { - return; - } - - var seriesTimerId = timer.SeriesTimerId; - var seriesTimer = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); - - if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) - { - return; - } - - if (_disposed) - { - return; - } - - using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false)) - { - if (_disposed) - { - return; - } - - var timersToDelete = _timerManager.GetAll() - .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath)) - .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(i => i.EndDate) - .Where(i => File.Exists(i.RecordingPath)) - .Skip(seriesTimer.KeepUpTo - 1) - .ToList(); - - DeleteLibraryItemsForTimers(timersToDelete); - - if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) - { - return; - } - - var episodesToDelete = librarySeries.GetItemList( - new InternalItemsQuery - { - OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, - IsVirtualItem = false, - IsFolder = false, - Recursive = true, - DtoOptions = new DtoOptions(true) - }) - .Where(i => i.IsFileProtocol && File.Exists(i.Path)) - .Skip(seriesTimer.KeepUpTo - 1) - .ToList(); - - foreach (var item in episodesToDelete) - { - try - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = true - }, - true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting item"); - } - } - } - } - - private void DeleteLibraryItemsForTimers(List timers) - { - foreach (var timer in timers) - { - if (_disposed) - { - return; - } - - try - { - DeleteLibraryItemForTimer(timer); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting recording"); - } - } - } - - private void DeleteLibraryItemForTimer(TimerInfo timer) - { - var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); - - if (libraryItem is not null) - { - _libraryManager.DeleteItem( - libraryItem, - new DeleteOptions - { - DeleteFileLocation = true - }, - true); - } - else if (File.Exists(timer.RecordingPath)) - { - _fileSystem.DeleteFile(timer.RecordingPath); - } - - _timerManager.Delete(timer); - } - - private string EnsureFileUnique(string path, string timerId) - { - var originalPath = path; - var index = 1; - - while (FileExists(path, timerId)) - { - var parent = Path.GetDirectoryName(originalPath); - var name = Path.GetFileNameWithoutExtension(originalPath); - name += " - " + index.ToString(CultureInfo.InvariantCulture); - - path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath)); - index++; - } - - return path; - } - - private bool FileExists(string path, string timerId) - { - if (File.Exists(path)) - { - return true; - } - - return _activeRecordings - .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); - } - - private IRecorder GetRecorder(MediaSourceInfo mediaSource) - { - if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) - { - return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); - } - - return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); - } - - private void OnSuccessfulRecording(TimerInfo timer, string path) - { - PostProcessRecording(timer, path); - } - - private void PostProcessRecording(TimerInfo timer, string path) - { - var options = _config.GetLiveTvConfiguration(); - if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) - { - return; - } - - try - { - var process = new Process - { - StartInfo = new ProcessStartInfo - { - Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments), - CreateNoWindow = true, - ErrorDialog = false, - FileName = options.RecordingPostProcessor, - WindowStyle = ProcessWindowStyle.Hidden, - UseShellExecute = false - }, - EnableRaisingEvents = true - }; - - _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); - - process.Exited += OnProcessExited; - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error running recording post processor"); - } - } - - private static string GetPostProcessArguments(string path, string arguments) - { - return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase); - } - - private void OnProcessExited(object sender, EventArgs e) - { - using (var process = (Process)sender) - { - _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); - } - } - private LiveTvProgram GetProgramInfoFromCache(string programId) { var query = new InternalItemsQuery @@ -1512,7 +735,8 @@ namespace Jellyfin.LiveTv.EmbyTV // Only update if not currently active - test both new timer and existing in case Id's are different // Id's could be different if the timer was created manually prior to series timer creation - else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) + else if (_recordingsManager.GetActiveRecordingPath(timer.Id) is null + && _recordingsManager.GetActiveRecordingPath(existingTimer.Id) is null) { UpdateExistingTimerWithNewMetadata(existingTimer, timer); @@ -1770,60 +994,5 @@ namespace Jellyfin.LiveTv.EmbyTV return false; } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _recordingDeleteSemaphore.Dispose(); - - foreach (var pair in _activeRecordings.ToList()) - { - pair.Value.CancellationTokenSource.Cancel(); - } - - _disposed = true; - } - - public IEnumerable GetRecordingFolders() - { - var defaultFolder = RecordingPath; - var defaultName = "Recordings"; - - if (Directory.Exists(defaultFolder)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { defaultFolder }, - Name = defaultName - }; - } - - var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath; - if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { customPath }, - Name = "Recorded Movies", - CollectionType = CollectionTypeOptions.Movies - }; - } - - customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath; - if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { customPath }, - Name = "Recorded Shows", - CollectionType = CollectionTypeOptions.TvShows - }; - } - } } } diff --git a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs index dc15d53ffa..18ff6a949f 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.LiveTv.Timers; using MediaBrowser.Controller.LiveTv; using Microsoft.Extensions.Hosting; @@ -12,19 +11,26 @@ namespace Jellyfin.LiveTv.EmbyTV; /// public sealed class LiveTvHost : IHostedService { - private readonly EmbyTV _service; + private readonly IRecordingsManager _recordingsManager; + private readonly TimerManager _timerManager; /// /// Initializes a new instance of the class. /// - /// The available s. - public LiveTvHost(IEnumerable services) + /// The . + /// The . + public LiveTvHost(IRecordingsManager recordingsManager, TimerManager timerManager) { - _service = services.OfType().First(); + _recordingsManager = recordingsManager; + _timerManager = timerManager; } /// - public Task StartAsync(CancellationToken cancellationToken) => _service.Start(); + public Task StartAsync(CancellationToken cancellationToken) + { + _timerManager.RestartTimers(); + return _recordingsManager.CreateRecordingFolders(); + } /// public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index d02be31cfa..e247ecb44d 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -28,12 +28,14 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 394fbbaeab..056bb6e6d2 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -34,6 +34,7 @@ public class GuideManager : IGuideManager private readonly ILibraryManager _libraryManager; private readonly ILiveTvManager _liveTvManager; private readonly ITunerHostManager _tunerHostManager; + private readonly IRecordingsManager _recordingsManager; private readonly LiveTvDtoService _tvDtoService; /// @@ -46,6 +47,7 @@ public class GuideManager : IGuideManager /// The . /// The . /// The . + /// The . /// The . public GuideManager( ILogger logger, @@ -55,6 +57,7 @@ public class GuideManager : IGuideManager ILibraryManager libraryManager, ILiveTvManager liveTvManager, ITunerHostManager tunerHostManager, + IRecordingsManager recordingsManager, LiveTvDtoService tvDtoService) { _logger = logger; @@ -64,6 +67,7 @@ public class GuideManager : IGuideManager _libraryManager = libraryManager; _liveTvManager = liveTvManager; _tunerHostManager = tunerHostManager; + _recordingsManager = recordingsManager; _tvDtoService = tvDtoService; } @@ -85,7 +89,7 @@ public class GuideManager : IGuideManager { ArgumentNullException.ThrowIfNull(progress); - await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); + await _recordingsManager.CreateRecordingFolders().ConfigureAwait(false); await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 6b4ce6f7c1..f7b9604afd 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -43,6 +43,7 @@ namespace Jellyfin.LiveTv private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly IChannelManager _channelManager; + private readonly IRecordingsManager _recordingsManager; private readonly LiveTvDtoService _tvDtoService; private readonly ILiveTvService[] _services; @@ -55,6 +56,7 @@ namespace Jellyfin.LiveTv ILibraryManager libraryManager, ILocalizationManager localization, IChannelManager channelManager, + IRecordingsManager recordingsManager, LiveTvDtoService liveTvDtoService, IEnumerable services) { @@ -67,6 +69,7 @@ namespace Jellyfin.LiveTv _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; + _recordingsManager = recordingsManager; _services = services.ToArray(); var defaultService = _services.OfType().First(); @@ -88,11 +91,6 @@ namespace Jellyfin.LiveTv /// The services. public IReadOnlyList Services => _services; - public string GetEmbyTvActiveRecordingPath(string id) - { - return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); - } - private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs e) { var timerId = e.Argument; @@ -765,18 +763,13 @@ namespace Jellyfin.LiveTv return AddRecordingInfo(programTuples, CancellationToken.None); } - public ActiveRecordingInfo GetActiveRecordingInfo(string path) - { - return EmbyTV.EmbyTV.Current.GetActiveRecordingInfo(path); - } - public void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null) { - var service = EmbyTV.EmbyTV.Current; - var info = activeRecordingInfo.Timer; - var channel = string.IsNullOrWhiteSpace(info.ChannelId) ? null : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(service.Name, info.ChannelId)); + var channel = string.IsNullOrWhiteSpace(info.ChannelId) + ? null + : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(EmbyTV.EmbyTV.ServiceName, info.ChannelId)); dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null @@ -1461,7 +1454,7 @@ namespace Jellyfin.LiveTv private async Task GetRecordingFoldersAsync(User user, bool refreshChannels) { - var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders() + var folders = _recordingsManager.GetRecordingFolders() .SelectMany(i => i.Locations) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(i => _libraryManager.FindByPath(i, true)) diff --git a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs index ce9361089c..c6874e4dbc 100644 --- a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs +++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs @@ -24,13 +24,15 @@ namespace Jellyfin.LiveTv private const char StreamIdDelimiter = '_'; private readonly ILiveTvManager _liveTvManager; + private readonly IRecordingsManager _recordingsManager; private readonly ILogger _logger; private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerApplicationHost _appHost; - public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost) + public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IRecordingsManager recordingsManager, ILogger logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost) { _liveTvManager = liveTvManager; + _recordingsManager = recordingsManager; _logger = logger; _mediaSourceManager = mediaSourceManager; _appHost = appHost; @@ -40,7 +42,7 @@ namespace Jellyfin.LiveTv { if (item.SourceType == SourceType.LiveTV) { - var activeRecordingInfo = _liveTvManager.GetActiveRecordingInfo(item.Path); + var activeRecordingInfo = _recordingsManager.GetActiveRecordingInfo(item.Path); if (string.IsNullOrEmpty(item.Path) || activeRecordingInfo is not null) { diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs new file mode 100644 index 0000000000..4ac205492f --- /dev/null +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -0,0 +1,849 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AsyncKeyedLock; +using Jellyfin.Data.Enums; +using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.EmbyTV; +using Jellyfin.LiveTv.IO; +using Jellyfin.LiveTv.Timers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Recordings; + +/// +public sealed class RecordingsManager : IRecordingsManager, IDisposable +{ + private readonly ILogger _logger; + private readonly IServerConfigurationManager _config; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILibraryMonitor _libraryMonitor; + private readonly IProviderManager _providerManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IStreamHelper _streamHelper; + private readonly TimerManager _timerManager; + private readonly SeriesTimerManager _seriesTimerManager; + private readonly RecordingsMetadataManager _recordingsMetadataManager; + + private readonly ConcurrentDictionary _activeRecordings = new(StringComparer.OrdinalIgnoreCase); + private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(); + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + public RecordingsManager( + ILogger logger, + IServerConfigurationManager config, + IHttpClientFactory httpClientFactory, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor, + IProviderManager providerManager, + IMediaEncoder mediaEncoder, + IMediaSourceManager mediaSourceManager, + IStreamHelper streamHelper, + TimerManager timerManager, + SeriesTimerManager seriesTimerManager, + RecordingsMetadataManager recordingsMetadataManager) + { + _logger = logger; + _config = config; + _httpClientFactory = httpClientFactory; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + _providerManager = providerManager; + _mediaEncoder = mediaEncoder; + _mediaSourceManager = mediaSourceManager; + _streamHelper = streamHelper; + _timerManager = timerManager; + _seriesTimerManager = seriesTimerManager; + _recordingsMetadataManager = recordingsMetadataManager; + + _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; + } + + private string DefaultRecordingPath + { + get + { + var path = _config.GetLiveTvConfiguration().RecordingPath; + + return string.IsNullOrWhiteSpace(path) + ? Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv", "recordings") + : path; + } + } + + /// + public string? GetActiveRecordingPath(string id) + => _activeRecordings.GetValueOrDefault(id)?.Path; + + /// + public ActiveRecordingInfo? GetActiveRecordingInfo(string path) + { + if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) + { + return null; + } + + foreach (var (_, recordingInfo) in _activeRecordings) + { + if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) + && !recordingInfo.CancellationTokenSource.IsCancellationRequested) + { + return recordingInfo.Timer.Status == RecordingStatus.InProgress ? recordingInfo : null; + } + } + + return null; + } + + /// + public IEnumerable GetRecordingFolders() + { + if (Directory.Exists(DefaultRecordingPath)) + { + yield return new VirtualFolderInfo + { + Locations = [DefaultRecordingPath], + Name = "Recordings" + }; + } + + var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath; + if (!string.IsNullOrWhiteSpace(customPath) + && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(customPath)) + { + yield return new VirtualFolderInfo + { + Locations = [customPath], + Name = "Recorded Movies", + CollectionType = CollectionTypeOptions.Movies + }; + } + + customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath; + if (!string.IsNullOrWhiteSpace(customPath) + && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(customPath)) + { + yield return new VirtualFolderInfo + { + Locations = [customPath], + Name = "Recorded Shows", + CollectionType = CollectionTypeOptions.TvShows + }; + } + } + + /// + public async Task CreateRecordingFolders() + { + try + { + var recordingFolders = GetRecordingFolders().ToArray(); + var virtualFolders = _libraryManager.GetVirtualFolders(); + + var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); + + var pathsAdded = new List(); + + foreach (var recordingFolder in recordingFolders) + { + var pathsToCreate = recordingFolder.Locations + .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i))) + .ToList(); + + if (pathsToCreate.Count == 0) + { + continue; + } + + var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); + var libraryOptions = new LibraryOptions + { + PathInfos = mediaPathInfos + }; + + try + { + await _libraryManager + .AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating virtual folder"); + } + + pathsAdded.AddRange(pathsToCreate); + } + + var config = _config.GetLiveTvConfiguration(); + + var pathsToRemove = config.MediaLocationsCreated + .Except(recordingFolders.SelectMany(i => i.Locations)) + .ToList(); + + if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) + { + pathsAdded.InsertRange(0, config.MediaLocationsCreated); + config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + _config.SaveConfiguration("livetv", config); + } + + foreach (var path in pathsToRemove) + { + await RemovePathFromLibraryAsync(path).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating recording folders"); + } + } + + private async Task RemovePathFromLibraryAsync(string path) + { + _logger.LogDebug("Removing path from library: {0}", path); + + var requiresRefresh = false; + var virtualFolders = _libraryManager.GetVirtualFolders(); + + foreach (var virtualFolder in virtualFolders) + { + if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + if (virtualFolder.Locations.Length == 1) + { + try + { + await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing virtual folder"); + } + } + else + { + try + { + _libraryManager.RemoveMediaPath(virtualFolder.Name, path); + requiresRefresh = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing media path"); + } + } + } + + if (requiresRefresh) + { + await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + } + } + + /// + public void CancelRecording(string timerId, TimerInfo? timer) + { + if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) + { + activeRecordingInfo.Timer = timer; + activeRecordingInfo.CancellationTokenSource.Cancel(); + } + } + + /// + public async Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate) + { + ArgumentNullException.ThrowIfNull(recordingInfo); + ArgumentNullException.ThrowIfNull(channel); + + var timer = recordingInfo.Timer; + var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); + var recordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath); + + string? liveStreamId = null; + RecordingStatus recordingStatus; + try + { + var allMediaSources = await _mediaSourceManager + .GetPlaybackMediaSources(channel, null, true, false, CancellationToken.None).ConfigureAwait(false); + + var mediaStreamInfo = allMediaSources[0]; + IDirectStreamProvider? directStreamProvider = null; + if (mediaStreamInfo.RequiresOpening) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( + new LiveStreamRequest + { + ItemId = channel.Id, + OpenToken = mediaStreamInfo.OpenToken + }, + CancellationToken.None).ConfigureAwait(false); + + mediaStreamInfo = liveStreamResponse.Item1.MediaSource; + liveStreamId = mediaStreamInfo.LiveStreamId; + directStreamProvider = liveStreamResponse.Item2; + } + + using var recorder = GetRecorder(mediaStreamInfo); + + recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath); + recordingPath = EnsureFileUnique(recordingPath, timer.Id); + + _libraryMonitor.ReportFileSystemChangeBeginning(recordingPath); + + var duration = recordingEndDate - DateTime.UtcNow; + + _logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes); + _logger.LogInformation("Writing file to: {Path}", recordingPath); + + async void OnStarted() + { + recordingInfo.Path = recordingPath; + _activeRecordings.TryAdd(timer.Id, recordingInfo); + + timer.Status = RecordingStatus.InProgress; + _timerManager.AddOrUpdate(timer, false); + + await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(false); + await CreateRecordingFolders().ConfigureAwait(false); + + TriggerRefresh(recordingPath); + await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); + } + + await recorder.Record( + directStreamProvider, + mediaStreamInfo, + recordingPath, + duration, + OnStarted, + recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); + + recordingStatus = RecordingStatus.Completed; + _logger.LogInformation("Recording completed: {RecordPath}", recordingPath); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Recording stopped: {RecordPath}", recordingPath); + recordingStatus = RecordingStatus.Completed; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording to {RecordPath}", recordingPath); + recordingStatus = RecordingStatus.Error; + } + + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream"); + } + } + + DeleteFileIfEmpty(recordingPath); + TriggerRefresh(recordingPath); + _libraryMonitor.ReportFileSystemChangeComplete(recordingPath, false); + _activeRecordings.TryRemove(timer.Id, out _); + + if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) + { + const int RetryIntervalSeconds = 60; + _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); + + timer.Status = RecordingStatus.New; + timer.PrePaddingSeconds = 0; + timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); + timer.RetryCount++; + _timerManager.AddOrUpdate(timer); + } + else if (File.Exists(recordingPath)) + { + timer.RecordingPath = recordingPath; + timer.Status = RecordingStatus.Completed; + _timerManager.AddOrUpdate(timer, false); + PostProcessRecording(recordingPath); + } + else + { + _timerManager.Delete(timer); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _recordingDeleteSemaphore.Dispose(); + + foreach (var pair in _activeRecordings.ToList()) + { + pair.Value.CancellationTokenSource.Cancel(); + } + + _disposed = true; + } + + private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e) + { + if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) + { + await CreateRecordingFolders().ConfigureAwait(false); + } + } + + private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) + { + if (!timer.IsSeries || timer.SeriesProviderIds.Count == 0) + { + return null; + } + + var query = new RemoteSearchQuery + { + SearchInfo = new SeriesInfo + { + ProviderIds = timer.SeriesProviderIds, + Name = timer.Name, + MetadataCountryCode = _config.Configuration.MetadataCountryCode, + MetadataLanguage = _config.Configuration.PreferredMetadataLanguage + } + }; + + var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); + + return results.FirstOrDefault(); + } + + private string GetRecordingPath(TimerInfo timer, RemoteSearchResult? metadata, out string? seriesPath) + { + var recordingPath = DefaultRecordingPath; + var config = _config.GetLiveTvConfiguration(); + seriesPath = null; + + if (timer.IsProgramSeries) + { + var customRecordingPath = config.SeriesRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase); + recordingPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Series"); + } + + // trim trailing period from the folder name + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); + + if (metadata is not null && metadata.ProductionYear.HasValue) + { + folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // Can't use the year here in the folder name because it is the year of the episode, not the series. + recordingPath = Path.Combine(recordingPath, folderName); + + seriesPath = recordingPath; + + if (timer.SeasonNumber.HasValue) + { + folderName = string.Format( + CultureInfo.InvariantCulture, + "Season {0}", + timer.SeasonNumber.Value); + recordingPath = Path.Combine(recordingPath, folderName); + } + } + else if (timer.IsMovie) + { + var customRecordingPath = config.MovieRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase); + recordingPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Movies"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // trim trailing period from the folder name + folderName = folderName.TrimEnd('.').Trim(); + + recordingPath = Path.Combine(recordingPath, folderName); + } + else if (timer.IsKids) + { + if (config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Kids"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // trim trailing period from the folder name + folderName = folderName.TrimEnd('.').Trim(); + + recordingPath = Path.Combine(recordingPath, folderName); + } + else if (timer.IsSports) + { + if (config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Sports"); + } + + recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + else + { + if (config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Other"); + } + + recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + + var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; + + return Path.Combine(recordingPath, recordingFileName); + } + + private void DeleteFileIfEmpty(string path) + { + var file = _fileSystem.GetFileInfo(path); + + if (file.Exists && file.Length == 0) + { + try + { + _fileSystem.DeleteFile(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); + } + } + } + + private void TriggerRefresh(string path) + { + _logger.LogInformation("Triggering refresh on {Path}", path); + + var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); + if (item is null) + { + return; + } + + _logger.LogInformation("Refreshing recording parent {Path}", item.Path); + _providerManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + RefreshPaths = + [ + path, + Path.GetDirectoryName(path), + Path.GetDirectoryName(Path.GetDirectoryName(path)) + ] + }, + RefreshPriority.High); + } + + private BaseItem? GetAffectedBaseItem(string? path) + { + BaseItem? item = null; + var parentPath = Path.GetDirectoryName(path); + while (item is null && !string.IsNullOrEmpty(path)) + { + item = _libraryManager.FindByPath(path, null); + path = Path.GetDirectoryName(path); + } + + if (item is not null + && item.GetType() == typeof(Folder) + && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) + { + var parentItem = item.GetParent(); + if (parentItem is not null && parentItem is not AggregateFolder) + { + item = parentItem; + } + } + + return item; + } + + private async Task EnforceKeepUpTo(TimerInfo timer, string? seriesPath) + { + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) + || string.IsNullOrWhiteSpace(seriesPath)) + { + return; + } + + var seriesTimerId = timer.SeriesTimerId; + var seriesTimer = _seriesTimerManager.GetAll() + .FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); + + if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) + { + return; + } + + if (_disposed) + { + return; + } + + using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false)) + { + if (_disposed) + { + return; + } + + var timersToDelete = _timerManager.GetAll() + .Where(timerInfo => timerInfo.Status == RecordingStatus.Completed + && !string.IsNullOrWhiteSpace(timerInfo.RecordingPath) + && string.Equals(timerInfo.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase) + && File.Exists(timerInfo.RecordingPath)) + .OrderByDescending(i => i.EndDate) + .Skip(seriesTimer.KeepUpTo - 1) + .ToList(); + + DeleteLibraryItemsForTimers(timersToDelete); + + if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) + { + return; + } + + var episodesToDelete = librarySeries.GetItemList( + new InternalItemsQuery + { + OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)], + IsVirtualItem = false, + IsFolder = false, + Recursive = true, + DtoOptions = new DtoOptions(true) + }) + .Where(i => i.IsFileProtocol && File.Exists(i.Path)) + .Skip(seriesTimer.KeepUpTo - 1); + + foreach (var item in episodesToDelete) + { + try + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting item"); + } + } + } + } + + private void DeleteLibraryItemsForTimers(List timers) + { + foreach (var timer in timers) + { + if (_disposed) + { + return; + } + + try + { + DeleteLibraryItemForTimer(timer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting recording"); + } + } + } + + private void DeleteLibraryItemForTimer(TimerInfo timer) + { + var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); + if (libraryItem is not null) + { + _libraryManager.DeleteItem( + libraryItem, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); + } + else if (File.Exists(timer.RecordingPath)) + { + _fileSystem.DeleteFile(timer.RecordingPath); + } + + _timerManager.Delete(timer); + } + + private string EnsureFileUnique(string path, string timerId) + { + var parent = Path.GetDirectoryName(path)!; + var name = Path.GetFileNameWithoutExtension(path); + var extension = Path.GetExtension(path); + + var index = 1; + while (File.Exists(path) || _activeRecordings.Any(i + => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) + && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase))) + { + name += " - " + index.ToString(CultureInfo.InvariantCulture); + + path = Path.ChangeExtension(Path.Combine(parent, name), extension); + index++; + } + + return path; + } + + private IRecorder GetRecorder(MediaSourceInfo mediaSource) + { + if (mediaSource.RequiresLooping + || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) + || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) + { + return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); + } + + return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); + } + + private void PostProcessRecording(string path) + { + var options = _config.GetLiveTvConfiguration(); + if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) + { + return; + } + + try + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + Arguments = options.RecordingPostProcessorArguments + .Replace("{path}", path, StringComparison.OrdinalIgnoreCase), + CreateNoWindow = true, + ErrorDialog = false, + FileName = options.RecordingPostProcessor, + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false + }, + EnableRaisingEvents = true + }; + + _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); + + process.Exited += OnProcessExited; + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running recording post processor"); + } + } + + private void OnProcessExited(object? sender, EventArgs e) + { + if (sender is Process process) + { + using (process) + { + _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); + } + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs index 33a9aca312..d5f6873a29 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs @@ -26,7 +26,7 @@ public class AudioResolverTests public AudioResolverTests() { // prep BaseItem and Video for calls made that expect managers - Video.LiveTvManager = Mock.Of(); + Video.RecordingsManager = Mock.Of(); var applicationPaths = new Mock().Object; var serverConfig = new Mock(); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 2b38675121..58b67ae553 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -37,7 +37,7 @@ public class MediaInfoResolverTests public MediaInfoResolverTests() { // prep BaseItem and Video for calls made that expect managers - Video.LiveTvManager = Mock.Of(); + Video.RecordingsManager = Mock.Of(); var applicationPaths = new Mock().Object; var serverConfig = new Mock(); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs index 0c1c269a4c..8077bd791c 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs @@ -26,7 +26,7 @@ public class SubtitleResolverTests public SubtitleResolverTests() { // prep BaseItem and Video for calls made that expect managers - Video.LiveTvManager = Mock.Of(); + Video.RecordingsManager = Mock.Of(); var applicationPaths = new Mock().Object; var serverConfig = new Mock(); -- cgit v1.2.3 From b5a3c71b3aba0d8a1e1e65f7d07e1caae43856e2 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 22 Feb 2024 10:28:02 -0500 Subject: Move media source code from LiveTvManager to LiveTvMediaSourceProvider --- MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 19 -- src/Jellyfin.LiveTv/LiveTvManager.cs | 189 ------------------- src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs | 220 ++++++++++++++++++++++- 3 files changed, 211 insertions(+), 217 deletions(-) (limited to 'MediaBrowser.Controller/LiveTv') diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index ed08cdc476..c0e46ba245 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -10,7 +10,6 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Events; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; @@ -105,16 +104,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task{QueryResult{SeriesTimerInfoDto}}. Task> GetSeriesTimers(SeriesTimerQuery query, CancellationToken cancellationToken); - /// - /// Gets the channel stream. - /// - /// The identifier. - /// The media source identifier. - /// The current live streams. - /// The cancellation token. - /// Task{StreamResponseInfo}. - Task> GetChannelStream(string id, string mediaSourceId, List currentLiveStreams, CancellationToken cancellationToken); - /// /// Gets the program. /// @@ -220,14 +209,6 @@ namespace MediaBrowser.Controller.LiveTv /// Internal channels. QueryResult GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken); - /// - /// Gets the channel media sources. - /// - /// Item to search for. - /// CancellationToken to use for operation. - /// Channel media sources wrapped in a task. - Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken); - /// /// Adds the information to program dto. /// diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 0af40a0597..c19d8195cd 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -12,7 +12,6 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.LiveTv.Configuration; -using Jellyfin.LiveTv.IO; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -152,73 +151,6 @@ namespace Jellyfin.LiveTv return _libraryManager.GetItemsResult(internalQuery); } - public async Task> GetChannelStream(string id, string mediaSourceId, List currentLiveStreams, CancellationToken cancellationToken) - { - if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - { - mediaSourceId = null; - } - - var channel = (LiveTvChannel)_libraryManager.GetItemById(id); - - bool isVideo = channel.ChannelType == ChannelType.TV; - var service = GetService(channel); - _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); - - MediaSourceInfo info; -#pragma warning disable CA1859 // TODO: Analyzer bug? - ILiveStream liveStream; -#pragma warning restore CA1859 - if (service is ISupportsDirectStreamProvider supportsManagedStream) - { - liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); - info = liveStream.MediaSource; - } - else - { - info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); - var openedId = info.Id; - Func closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None); - - liveStream = new ExclusiveLiveStream(info, closeFn); - - var startTime = DateTime.UtcNow; - await liveStream.Open(cancellationToken).ConfigureAwait(false); - var endTime = DateTime.UtcNow; - _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); - } - - info.RequiresClosing = true; - - var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; - - info.LiveStreamId = idPrefix + info.Id; - - Normalize(info, service, isVideo); - - return new Tuple(info, liveStream); - } - - public async Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken) - { - var baseItem = (LiveTvChannel)item; - var service = GetService(baseItem); - - var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); - - if (sources.Count == 0) - { - throw new NotImplementedException(); - } - - foreach (var source in sources) - { - Normalize(source, service, baseItem.ChannelType == ChannelType.TV); - } - - return sources; - } - private ILiveTvService GetService(LiveTvChannel item) { var name = item.ServiceName; @@ -240,127 +172,6 @@ namespace Jellyfin.LiveTv "No service with the name '{0}' can be found.", name)); - private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) - { - // Not all of the plugins are setting this - mediaSource.IsInfiniteStream = true; - - if (mediaSource.MediaStreams.Count == 0) - { - if (isVideo) - { - mediaSource.MediaStreams = new MediaStream[] - { - new MediaStream - { - Type = MediaStreamType.Video, - // Set the index to -1 because we don't know the exact index of the video stream within the container - Index = -1, - - // Set to true if unknown to enable deinterlacing - IsInterlaced = true - }, - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1 - } - }; - } - else - { - mediaSource.MediaStreams = new MediaStream[] - { - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1 - } - }; - } - } - - // Clean some bad data coming from providers - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.BitRate.HasValue && stream.BitRate <= 0) - { - stream.BitRate = null; - } - - if (stream.Channels.HasValue && stream.Channels <= 0) - { - stream.Channels = null; - } - - if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0) - { - stream.AverageFrameRate = null; - } - - if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0) - { - stream.RealFrameRate = null; - } - - if (stream.Width.HasValue && stream.Width <= 0) - { - stream.Width = null; - } - - if (stream.Height.HasValue && stream.Height <= 0) - { - stream.Height = null; - } - - if (stream.SampleRate.HasValue && stream.SampleRate <= 0) - { - stream.SampleRate = null; - } - - if (stream.Level.HasValue && stream.Level <= 0) - { - stream.Level = null; - } - } - - var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList(); - - // If there are duplicate stream indexes, set them all to unknown - if (indexes.Count != mediaSource.MediaStreams.Count) - { - foreach (var stream in mediaSource.MediaStreams) - { - stream.Index = -1; - } - } - - // Set the total bitrate if not already supplied - mediaSource.InferTotalBitrate(); - - if (service is not DefaultLiveTvService) - { - // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says - // mediaSource.SupportsDirectPlay = false; - // mediaSource.SupportsDirectStream = false; - mediaSource.SupportsTranscoding = true; - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) - { - stream.NalLengthSize = "0"; - } - - if (stream.Type == MediaStreamType.Video) - { - stream.IsInterlaced = true; - } - } - } - } - public async Task GetProgram(string id, CancellationToken cancellationToken, User user = null) { var program = _libraryManager.GetItemById(id); diff --git a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs index c6874e4dbc..40ac5ce0fd 100644 --- a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs +++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs @@ -8,11 +8,15 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.LiveTv.IO; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; @@ -23,19 +27,27 @@ namespace Jellyfin.LiveTv // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. private const char StreamIdDelimiter = '_'; - private readonly ILiveTvManager _liveTvManager; - private readonly IRecordingsManager _recordingsManager; private readonly ILogger _logger; - private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerApplicationHost _appHost; + private readonly IRecordingsManager _recordingsManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly ILibraryManager _libraryManager; + private readonly ILiveTvService[] _services; - public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IRecordingsManager recordingsManager, ILogger logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost) + public LiveTvMediaSourceProvider( + ILogger logger, + IServerApplicationHost appHost, + IRecordingsManager recordingsManager, + IMediaSourceManager mediaSourceManager, + ILibraryManager libraryManager, + IEnumerable services) { - _liveTvManager = liveTvManager; - _recordingsManager = recordingsManager; _logger = logger; - _mediaSourceManager = mediaSourceManager; _appHost = appHost; + _recordingsManager = recordingsManager; + _mediaSourceManager = mediaSourceManager; + _libraryManager = libraryManager; + _services = services.ToArray(); } public Task> GetMediaSources(BaseItem item, CancellationToken cancellationToken) @@ -68,7 +80,7 @@ namespace Jellyfin.LiveTv } else { - sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken) + sources = await GetChannelMediaSources(item, cancellationToken) .ConfigureAwait(false); } } @@ -121,10 +133,200 @@ namespace Jellyfin.LiveTv var keys = openToken.Split(StreamIdDelimiter, 3); var mediaSourceId = keys.Length >= 3 ? keys[2] : null; - var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + var info = await GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); var liveStream = info.Item2; return liveStream; } + + private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) + { + // Not all of the plugins are setting this + mediaSource.IsInfiniteStream = true; + + if (mediaSource.MediaStreams.Count == 0) + { + if (isVideo) + { + mediaSource.MediaStreams = new[] + { + new MediaStream + { + Type = MediaStreamType.Video, + // Set the index to -1 because we don't know the exact index of the video stream within the container + Index = -1, + // Set to true if unknown to enable deinterlacing + IsInterlaced = true + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }; + } + else + { + mediaSource.MediaStreams = new[] + { + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }; + } + } + + // Clean some bad data coming from providers + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.BitRate is <= 0) + { + stream.BitRate = null; + } + + if (stream.Channels is <= 0) + { + stream.Channels = null; + } + + if (stream.AverageFrameRate is <= 0) + { + stream.AverageFrameRate = null; + } + + if (stream.RealFrameRate is <= 0) + { + stream.RealFrameRate = null; + } + + if (stream.Width is <= 0) + { + stream.Width = null; + } + + if (stream.Height is <= 0) + { + stream.Height = null; + } + + if (stream.SampleRate is <= 0) + { + stream.SampleRate = null; + } + + if (stream.Level is <= 0) + { + stream.Level = null; + } + } + + var indexCount = mediaSource.MediaStreams.Select(i => i.Index).Distinct().Count(); + + // If there are duplicate stream indexes, set them all to unknown + if (indexCount != mediaSource.MediaStreams.Count) + { + foreach (var stream in mediaSource.MediaStreams) + { + stream.Index = -1; + } + } + + // Set the total bitrate if not already supplied + mediaSource.InferTotalBitrate(); + + if (service is not DefaultLiveTvService) + { + mediaSource.SupportsTranscoding = true; + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) + { + stream.NalLengthSize = "0"; + } + + if (stream.Type == MediaStreamType.Video) + { + stream.IsInterlaced = true; + } + } + } + } + + private async Task> GetChannelStream( + string id, + string mediaSourceId, + List currentLiveStreams, + CancellationToken cancellationToken) + { + if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + { + mediaSourceId = null; + } + + var channel = (LiveTvChannel)_libraryManager.GetItemById(id); + + bool isVideo = channel.ChannelType == ChannelType.TV; + var service = GetService(channel.ServiceName); + _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); + + MediaSourceInfo info; +#pragma warning disable CA1859 // TODO: Analyzer bug? + ILiveStream liveStream; +#pragma warning restore CA1859 + if (service is ISupportsDirectStreamProvider supportsManagedStream) + { + liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + info = liveStream.MediaSource; + } + else + { + info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); + var openedId = info.Id; + Func closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None); + + liveStream = new ExclusiveLiveStream(info, closeFn); + + var startTime = DateTime.UtcNow; + await liveStream.Open(cancellationToken).ConfigureAwait(false); + var endTime = DateTime.UtcNow; + _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); + } + + info.RequiresClosing = true; + + var idPrefix = service.GetType().FullName!.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; + + info.LiveStreamId = idPrefix + info.Id; + + Normalize(info, service, isVideo); + + return new Tuple(info, liveStream); + } + + private async Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken) + { + var baseItem = (LiveTvChannel)item; + var service = GetService(baseItem.ServiceName); + + var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); + if (sources.Count == 0) + { + throw new NotImplementedException(); + } + + foreach (var source in sources) + { + Normalize(source, service, baseItem.ChannelType == ChannelType.TV); + } + + return sources; + } + + private ILiveTvService GetService(string name) + => _services.First(service => string.Equals(service.Name, name, StringComparison.OrdinalIgnoreCase)); } } -- cgit v1.2.3