From bbce1beb1d136d849141a5a5e634fed729fc6698 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 10 Jan 2024 16:54:33 -0500 Subject: Don't re-use HttpRequestMessage on re-try in SchedulesDirect --- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 3b20cd160..5c0e96c67 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -598,14 +598,14 @@ namespace Jellyfin.LiveTv.Listings } private async Task Send( - HttpRequestMessage options, + HttpRequestMessage message, bool enableRetry, ListingsProviderInfo providerInfo, CancellationToken cancellationToken, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { - var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + using var client = _httpClientFactory.CreateClient(NamedClient.Default); + var response = await client.SendAsync(message, completionOption, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { return response; @@ -625,8 +625,13 @@ namespace Jellyfin.LiveTv.Listings #pragma warning restore IDISP016, IDISP017 _tokens.Clear(); - options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); - return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); + using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri); + retryMessage.Content = message.Content; + retryMessage.Headers.TryAddWithoutValidation( + "token", + await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + + return await Send(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false); } private async Task GetTokenInternal( -- cgit v1.2.3 From f87a5490adce83c32362a14518d6f6e3e5a24917 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 10 Jan 2024 17:06:40 -0500 Subject: Fix disposable analyzer warnings in SchedulesDirect --- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 43 ++++++++----------------- 1 file changed, 14 insertions(+), 29 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 5c0e96c67..5728146f7 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -105,8 +105,7 @@ namespace Jellyfin.LiveTv.Listings using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); options.Content = JsonContent.Create(requestList, options: _jsonOptions); options.Headers.TryAddWithoutValidation("token", token); - using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - var dailySchedules = await response.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var dailySchedules = await Request>(options, true, info, cancellationToken).ConfigureAwait(false); if (dailySchedules is null) { return Array.Empty(); @@ -120,8 +119,8 @@ namespace Jellyfin.LiveTv.Listings var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); - using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); - var programDetails = await innerResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var programDetails = await Request>(programRequestOptions, true, info, cancellationToken) + .ConfigureAwait(false); if (programDetails is null) { return Array.Empty(); @@ -471,16 +470,13 @@ namespace Jellyfin.LiveTv.Listings str.Length--; str.Append(']'); - using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") - { - Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) - }; + using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs"); message.Headers.TryAddWithoutValidation("token", token); + message.Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json); try { - using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); - return await innerResponse2.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + return await Request>(message, true, info, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -506,8 +502,7 @@ namespace Jellyfin.LiveTv.Listings try { - using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); - var root = await httpResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await Request>(options, false, info, cancellationToken).ConfigureAwait(false); if (root is not null) { foreach (HeadendsDto headend in root) @@ -597,7 +592,7 @@ namespace Jellyfin.LiveTv.Listings } } - private async Task Send( + private async Task Request( HttpRequestMessage message, bool enableRetry, ListingsProviderInfo providerInfo, @@ -605,16 +600,12 @@ namespace Jellyfin.LiveTv.Listings HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { using var client = _httpClientFactory.CreateClient(NamedClient.Default); - var response = await client.SendAsync(message, completionOption, cancellationToken).ConfigureAwait(false); + using var response = await client.SendAsync(message, completionOption, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { - return response; + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); } - // Response is automatically disposed in the calling function, - // so dispose manually if not returning. -#pragma warning disable IDISP016, IDISP017 - response.Dispose(); if (!enableRetry || (int)response.StatusCode >= 500) { throw new HttpRequestException( @@ -622,7 +613,6 @@ namespace Jellyfin.LiveTv.Listings null, response.StatusCode); } -#pragma warning restore IDISP016, IDISP017 _tokens.Clear(); using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri); @@ -631,7 +621,7 @@ namespace Jellyfin.LiveTv.Listings "token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); - return await Send(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false); + return await Request(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false); } private async Task GetTokenInternal( @@ -648,9 +638,7 @@ namespace Jellyfin.LiveTv.Listings string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); - using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - var root = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await Request(options, false, null, cancellationToken).ConfigureAwait(false); if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) { _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); @@ -689,9 +677,7 @@ namespace Jellyfin.LiveTv.Listings try { - using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - httpResponse.EnsureSuccessStatusCode(); - var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await Request(options, false, null, cancellationToken).ConfigureAwait(false); return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; } catch (HttpRequestException ex) @@ -744,8 +730,7 @@ namespace Jellyfin.LiveTv.Listings using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); options.Headers.TryAddWithoutValidation("token", token); - using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await Request(options, true, info, cancellationToken).ConfigureAwait(false); if (root is null) { return new List(); -- cgit v1.2.3 From e47144e7c777751b03caf7cbb64cf93f92725725 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 12:11:16 +0100 Subject: Updated contributors, upgraded to AsyncKeyedLocker 6.3.0 which now supports non-keyed locking using a similar interface and changed SemaphoreSlim-based locks to using AsyncNonKeyedLocker. --- CONTRIBUTORS.md | 1 + Directory.Packages.props | 2 +- .../Library/MediaSourceManager.cs | 21 +--- .../Jellyfin.Server.Implementations.csproj | 1 + .../Trickplay/TrickplayManager.cs | 140 ++++++++++----------- .../MediaEncoding/ITranscodeManager.cs | 2 +- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 12 +- .../Transcoding/TranscodeManager.cs | 2 +- src/Jellyfin.Drawing/ImageProcessor.cs | 16 +-- src/Jellyfin.Drawing/Jellyfin.Drawing.csproj | 4 + src/Jellyfin.LiveTv/Channels/ChannelManager.cs | 11 +- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 11 +- src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj | 3 +- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 37 +++--- 14 files changed, 120 insertions(+), 143 deletions(-) (limited to 'src') diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4e45fd24a..250f5d54d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -77,6 +77,7 @@ - [Marenz](https://github.com/Marenz) - [marius-luca-87](https://github.com/marius-luca-87) - [mark-monteiro](https://github.com/mark-monteiro) + - [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti) - [Matt07211](https://github.com/Matt07211) - [Maxr1998](https://github.com/Maxr1998) - [mcarlton00](https://github.com/mcarlton00) diff --git a/Directory.Packages.props b/Directory.Packages.props index ebb6038d8..29b9030ac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - + diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 68eccf311..ec6029faf 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; @@ -51,7 +52,7 @@ namespace Emby.Server.Implementations.Library private readonly IDirectoryService _directoryService; private readonly ConcurrentDictionary _openStreams = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _liveStreamLocker = new(1); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private IMediaSourceProvider[] _providers; @@ -467,12 +468,10 @@ namespace Emby.Server.Implementations.Library public async Task> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) { - await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - MediaSourceInfo mediaSource; ILiveStream liveStream; - try + using (await _liveStreamLocker.LockAsync(cancellationToken).ConfigureAwait(false)) { var (provider, keyId) = GetProvider(request.OpenToken); @@ -492,10 +491,6 @@ namespace Emby.Server.Implementations.Library _openStreams[mediaSource.LiveStreamId] = liveStream; } - finally - { - _liveStreamSemaphore.Release(); - } try { @@ -836,9 +831,7 @@ namespace Emby.Server.Implementations.Library { ArgumentException.ThrowIfNullOrEmpty(id); - await _liveStreamSemaphore.WaitAsync().ConfigureAwait(false); - - try + using (await _liveStreamLocker.LockAsync().ConfigureAwait(false)) { if (_openStreams.TryGetValue(id, out ILiveStream liveStream)) { @@ -857,10 +850,6 @@ namespace Emby.Server.Implementations.Library } } } - finally - { - _liveStreamSemaphore.Release(); - } } private (IMediaSourceProvider MediaSourceProvider, string KeyId) GetProvider(string key) @@ -897,7 +886,7 @@ namespace Emby.Server.Implementations.Library CloseLiveStream(key).GetAwaiter().GetResult(); } - _liveStreamSemaphore.Dispose(); + _liveStreamLocker.Dispose(); } } } diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 0ed1578c7..7c4155bfc 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -26,6 +26,7 @@ + diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index b960feb7f..f6854157a 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; @@ -37,7 +38,7 @@ public class TrickplayManager : ITrickplayManager private readonly IDbContextFactory _dbProvider; private readonly IApplicationPaths _appPaths; - private static readonly SemaphoreSlim _resourcePool = new(1, 1); + private static readonly AsyncNonKeyedLocker _resourcePool = new(1); private static readonly string[] _trickplayImgExtensions = { ".jpg" }; /// @@ -107,93 +108,92 @@ public class TrickplayManager : ITrickplayManager var imgTempDir = string.Empty; var outputDir = GetTrickplayDirectory(video, width); - await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { - if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width)) - { - _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id); - return; - } - - // Extract images - // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay. - var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id)); - - if (mediaSource is null) + try { - _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); - return; - } + if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width)) + { + _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id); + return; + } - var mediaPath = mediaSource.Path; - var mediaStream = mediaSource.VideoStream; - var container = mediaSource.Container; + // Extract images + // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay. + var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id)); - _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id); - imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated( - mediaPath, - container, - mediaSource, - mediaStream, - width, - TimeSpan.FromMilliseconds(options.Interval), - options.EnableHwAcceleration, - options.ProcessThreads, - options.Qscale, - options.ProcessPriority, - _encodingHelper, - cancellationToken).ConfigureAwait(false); + if (mediaSource is null) + { + _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); + return; + } - if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir)) - { - throw new InvalidOperationException("Null or invalid directory from media encoder."); - } + var mediaPath = mediaSource.Path; + var mediaStream = mediaSource.VideoStream; + var container = mediaSource.Container; + + _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id); + imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated( + mediaPath, + container, + mediaSource, + mediaStream, + width, + TimeSpan.FromMilliseconds(options.Interval), + options.EnableHwAcceleration, + options.ProcessThreads, + options.Qscale, + options.ProcessPriority, + _encodingHelper, + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir)) + { + throw new InvalidOperationException("Null or invalid directory from media encoder."); + } - var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false) - .Select(i => i.FullName) - .OrderBy(i => i) - .ToList(); + var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false) + .Select(i => i.FullName) + .OrderBy(i => i) + .ToList(); - // Create tiles - var trickplayInfo = CreateTiles(images, width, options, outputDir); + // Create tiles + var trickplayInfo = CreateTiles(images, width, options, outputDir); - // Save tiles info - try - { - if (trickplayInfo is not null) + // Save tiles info + try { - trickplayInfo.ItemId = video.Id; - await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false); + if (trickplayInfo is not null) + { + trickplayInfo.ItemId = video.Id; + await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false); - _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); + _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); + } + else + { + throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); + } } - else + catch (Exception ex) { - throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); + _logger.LogError(ex, "Error while saving trickplay tiles info."); + + // Make sure no files stay in metadata folders on failure + // if tiles info wasn't saved. + Directory.Delete(outputDir, true); } } catch (Exception ex) { - _logger.LogError(ex, "Error while saving trickplay tiles info."); - - // Make sure no files stay in metadata folders on failure - // if tiles info wasn't saved. - Directory.Delete(outputDir, true); + _logger.LogError(ex, "Error creating trickplay images."); } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating trickplay images."); - } - finally - { - _resourcePool.Release(); - - if (!string.IsNullOrEmpty(imgTempDir)) + finally { - Directory.Delete(imgTempDir, true); + if (!string.IsNullOrEmpty(imgTempDir)) + { + Directory.Delete(imgTempDir, true); + } } } } diff --git a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs index 3b410d1ba..09bc01f74 100644 --- a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs +++ b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs @@ -100,6 +100,6 @@ public interface ITranscodeManager /// /// The output path of the transcoded file. /// The cancellation token. - /// A . + /// An . ValueTask LockAsync(string outputPath, CancellationToken cancellationToken); } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 4dbefca4b..7d5ec615a 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; @@ -60,7 +61,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly IServerConfigurationManager _serverConfig; private readonly string _startupOptionFFmpegPath; - private readonly SemaphoreSlim _thumbnailResourcePool; + private readonly AsyncNonKeyedLocker _thumbnailResourcePool; private readonly object _runningProcessesLock = new object(); private readonly List _runningProcesses = new List(); @@ -116,7 +117,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _jsonSerializerOptions.Converters.Add(new JsonBoolStringConverter()); var semaphoreCount = 2 * Environment.ProcessorCount; - _thumbnailResourcePool = new SemaphoreSlim(semaphoreCount, semaphoreCount); + _thumbnailResourcePool = new(semaphoreCount); } /// @@ -754,8 +755,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { bool ranToCompletion; - await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using (await _thumbnailResourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { StartProcess(processWrapper); @@ -776,10 +776,6 @@ namespace MediaBrowser.MediaEncoding.Encoder ranToCompletion = false; } } - finally - { - _thumbnailResourcePool.Release(); - } var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1; var file = _fileSystem.GetFileInfo(tempExtractPath); diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index db45d2cdd..bb61d7fa6 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -727,7 +727,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable /// /// The output path of the transcoded file. /// The cancellation token. - /// A . + /// An . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask LockAsync(string outputPath, CancellationToken cancellationToken) { diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 65a8f4e83..213328a39 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -7,6 +7,7 @@ using System.Net.Mime; using System.Text; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -38,7 +39,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable private readonly IServerApplicationPaths _appPaths; private readonly IImageEncoder _imageEncoder; - private readonly SemaphoreSlim _parallelEncodingLimit; + private readonly AsyncNonKeyedLocker _parallelEncodingLimit; private bool _disposed; @@ -68,7 +69,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable semaphoreCount = 2 * Environment.ProcessorCount; } - _parallelEncodingLimit = new(semaphoreCount, semaphoreCount); + _parallelEncodingLimit = new(semaphoreCount); } private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images"); @@ -193,18 +194,13 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable { if (!File.Exists(cacheFilePath)) { - // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage - await _parallelEncodingLimit.WaitAsync().ConfigureAwait(false); - string resultPath; - try + + // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage + using (await _parallelEncodingLimit.LockAsync().ConfigureAwait(false)) { resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); } - finally - { - _parallelEncodingLimit.Release(); - } if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index 23c4c0a9a..4a02f90f9 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index f5ce75ff4..bf735ddd0 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -50,7 +51,7 @@ namespace Jellyfin.LiveTv.Channels private readonly IFileSystem _fileSystem; private readonly IProviderManager _providerManager; private readonly IMemoryCache _memoryCache; - private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _resourcePool = new(1); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private bool _disposed = false; @@ -832,9 +833,7 @@ namespace Jellyfin.LiveTv.Channels { } - await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { try { @@ -881,10 +880,6 @@ namespace Jellyfin.LiveTv.Channels return result; } - finally - { - _resourcePool.Release(); - } } private async Task CacheResponse(ChannelItemResult result, string path) diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 439ed965b..20ede63b0 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -14,6 +14,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; +using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; @@ -68,7 +69,7 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly ConcurrentDictionary _epgChannels = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1); private bool _disposed; @@ -1447,9 +1448,7 @@ namespace Jellyfin.LiveTv.EmbyTV return; } - await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); - - try + using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false)) { if (_disposed) { @@ -1502,10 +1501,6 @@ namespace Jellyfin.LiveTv.EmbyTV } } } - finally - { - _recordingDeleteSemaphore.Release(); - } } private void DeleteLibraryItemsForTimers(List timers) diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj index 5a826a1da..c58889740 100644 --- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -1,4 +1,4 @@ - + net8.0 true @@ -11,6 +11,7 @@ + diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 3b20cd160..b237f5b16 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -16,6 +16,7 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; @@ -35,7 +36,7 @@ namespace Jellyfin.LiveTv.Listings private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; - private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _tokenLock = new(1); private readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; @@ -573,27 +574,25 @@ namespace Jellyfin.LiveTv.Listings } } - await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); - savedToken.Name = result; - savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); - return result; - } - catch (HttpRequestException ex) + using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false)) { - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + try { - _tokens.Clear(); - _lastErrorResponse = DateTime.UtcNow; + var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); + savedToken.Name = result; + savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); + return result; } + catch (HttpRequestException ex) + { + if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + { + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } - throw; - } - finally - { - _tokenSemaphore.Release(); + throw; + } } } @@ -801,7 +800,7 @@ namespace Jellyfin.LiveTv.Listings if (disposing) { - _tokenSemaphore?.Dispose(); + _tokenLock?.Dispose(); } _disposed = true; -- cgit v1.2.3 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 --- Jellyfin.Api/Controllers/LiveTvController.cs | 8 +- MediaBrowser.Controller/LiveTv/IGuideManager.cs | 26 + MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 6 - .../LiveTvServiceCollectionExtensions.cs | 2 + src/Jellyfin.LiveTv/Guide/GuideManager.cs | 713 +++++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 668 +------------------ src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs | 14 +- 7 files changed, 755 insertions(+), 682 deletions(-) create mode 100644 MediaBrowser.Controller/LiveTv/IGuideManager.cs create mode 100644 src/Jellyfin.LiveTv/Guide/GuideManager.cs (limited to 'src') diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 27eb88b60..35cb97047 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -42,6 +42,7 @@ namespace Jellyfin.Api.Controllers; public class LiveTvController : BaseJellyfinApiController { private readonly ILiveTvManager _liveTvManager; + private readonly IGuideManager _guideManager; private readonly ITunerHostManager _tunerHostManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; @@ -55,6 +56,7 @@ public class LiveTvController : BaseJellyfinApiController /// Initializes a new instance of the class. /// /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -65,6 +67,7 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. public LiveTvController( ILiveTvManager liveTvManager, + IGuideManager guideManager, ITunerHostManager tunerHostManager, IUserManager userManager, IHttpClientFactory httpClientFactory, @@ -75,6 +78,7 @@ public class LiveTvController : BaseJellyfinApiController ITranscodeManager transcodeManager) { _liveTvManager = liveTvManager; + _guideManager = guideManager; _tunerHostManager = tunerHostManager; _userManager = userManager; _httpClientFactory = httpClientFactory; @@ -940,9 +944,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult GetGuideInfo() - { - return _liveTvManager.GetGuideInfo(); - } + => _guideManager.GetGuideInfo(); /// /// Adds a tuner host. diff --git a/MediaBrowser.Controller/LiveTv/IGuideManager.cs b/MediaBrowser.Controller/LiveTv/IGuideManager.cs new file mode 100644 index 000000000..9883b9283 --- /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 26f9fe42d..2dbc2cf82 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. /// diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index 5490547ec..21dab69e0 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Jellyfin.LiveTv.Channels; +using Jellyfin.LiveTv.Guide; using Jellyfin.LiveTv.TunerHosts; using Jellyfin.LiveTv.TunerHosts.HdHomerun; using MediaBrowser.Controller.Channels; @@ -24,6 +25,7 @@ public static class LiveTvServiceCollectionExtensions 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 new file mode 100644 index 000000000..21b41e9cc --- /dev/null +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -0,0 +1,713 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.LiveTv.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Guide; + +/// +public class GuideManager : IGuideManager +{ + private const int MaxGuideDays = 14; + private const string EtagKey = "ProgramEtag"; + private const string ExternalServiceTag = "ExternalServiceId"; + + private readonly ILogger _logger; + private readonly IConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IItemRepository _itemRepo; + private readonly ILibraryManager _libraryManager; + private readonly ILiveTvManager _liveTvManager; + private readonly ITunerHostManager _tunerHostManager; + private readonly LiveTvDtoService _tvDtoService; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + public GuideManager( + ILogger logger, + IConfigurationManager config, + IFileSystem fileSystem, + IItemRepository itemRepo, + ILibraryManager libraryManager, + ILiveTvManager liveTvManager, + ITunerHostManager tunerHostManager, + LiveTvDtoService tvDtoService) + { + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _itemRepo = itemRepo; + _libraryManager = libraryManager; + _liveTvManager = liveTvManager; + _tunerHostManager = tunerHostManager; + _tvDtoService = tvDtoService; + } + + /// + public GuideInfo GetGuideInfo() + { + var startDate = DateTime.UtcNow; + var endDate = startDate.AddDays(GetGuideDays()); + + return new GuideInfo + { + StartDate = startDate, + EndDate = endDate + }; + } + + /// + public async Task RefreshGuide(IProgress progress, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(progress); + + await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); + + await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); + + var numComplete = 0; + double progressPerService = _liveTvManager.Services.Count == 0 + ? 0 + : 1.0 / _liveTvManager.Services.Count; + + var newChannelIdList = new List(); + var newProgramIdList = new List(); + + var cleanDatabase = true; + + foreach (var service in _liveTvManager.Services) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogDebug("Refreshing guide from {Name}", service.Name); + + try + { + var innerProgress = new ActionableProgress(); + innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); + + var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); + + newChannelIdList.AddRange(idList.Item1); + newProgramIdList.AddRange(idList.Item2); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + cleanDatabase = false; + _logger.LogError(ex, "Error refreshing channels for service"); + } + + numComplete++; + double percent = numComplete; + percent /= _liveTvManager.Services.Count; + + progress.Report(100 * percent); + } + + if (cleanDatabase) + { + CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken); + CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken); + } + + var coreService = _liveTvManager.Services.OfType().FirstOrDefault(); + if (coreService is not null) + { + await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); + await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); + } + + // Load these now which will prefetch metadata + var dtoOptions = new DtoOptions(); + var fields = dtoOptions.Fields.ToList(); + dtoOptions.Fields = fields.ToArray(); + + progress.Report(100); + } + + private double GetGuideDays() + { + var config = _config.GetLiveTvConfiguration(); + + return config.GuideDays.HasValue + ? Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)) + : 7; + } + + private async Task, List>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress progress, CancellationToken cancellationToken) + { + progress.Report(10); + + var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) + .Select(i => new Tuple(service.Name, i)) + .ToList(); + + var list = new List(); + + var numComplete = 0; + var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken); + + foreach (var channelInfo in allChannelsList) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); + + list.Add(item); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); + } + + numComplete++; + double percent = numComplete; + percent /= allChannelsList.Count; + + progress.Report((5 * percent) + 10); + } + + progress.Report(15); + + numComplete = 0; + var programs = new List(); + var channels = new List(); + + var guideDays = GetGuideDays(); + + _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); + + foreach (var currentChannel in list) + { + cancellationToken.ThrowIfCancellationRequested(); + channels.Add(currentChannel.Id); + + try + { + var start = DateTime.UtcNow.AddHours(-1); + var end = start.AddDays(guideDays); + + var isMovie = false; + var isSports = false; + var isNews = false; + var isKids = false; + var isSeries = false; + + var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); + + var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.LiveTvProgram], + ChannelIds = new[] { currentChannel.Id }, + DtoOptions = new DtoOptions(true) + }).Cast().ToDictionary(i => i.Id); + + var newPrograms = new List(); + var updatedPrograms = new List(); + + foreach (var program in channelPrograms) + { + var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel); + if (isNew) + { + newPrograms.Add(programItem); + } + else if (isUpdated) + { + updatedPrograms.Add(programItem); + } + + programs.Add(programItem.Id); + + isMovie |= program.IsMovie; + isSeries |= program.IsSeries; + isSports |= program.IsSports; + isNews |= program.IsNews; + isKids |= program.IsKids; + } + + _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); + + if (newPrograms.Count > 0) + { + _libraryManager.CreateItems(newPrograms, null, cancellationToken); + } + + if (updatedPrograms.Count > 0) + { + await _libraryManager.UpdateItemsAsync( + updatedPrograms, + currentChannel, + ItemUpdateType.MetadataImport, + cancellationToken).ConfigureAwait(false); + } + + currentChannel.IsMovie = isMovie; + currentChannel.IsNews = isNews; + currentChannel.IsSports = isSports; + currentChannel.IsSeries = isSeries; + + if (isKids) + { + currentChannel.AddTag("Kids"); + } + + await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await currentChannel.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); + } + + numComplete++; + double percent = numComplete / (double)allChannelsList.Count; + + progress.Report((85 * percent) + 15); + } + + progress.Report(100); + return new Tuple, List>(channels, programs); + } + + private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress progress, CancellationToken cancellationToken) + { + var list = _itemRepo.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = validTypes, + DtoOptions = new DtoOptions(false) + }); + + var numComplete = 0; + + foreach (var itemId in list) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (itemId.Equals(default)) + { + // Somehow some invalid data got into the db. It probably predates the boundary checking + continue; + } + + if (!currentIdList.Contains(itemId)) + { + var item = _libraryManager.GetItemById(itemId); + + if (item is not null) + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + false); + } + } + + numComplete++; + double percent = numComplete / (double)list.Count; + + progress.Report(100 * percent); + } + } + + private async Task GetChannel( + ChannelInfo channelInfo, + string serviceName, + BaseItem parentFolder, + CancellationToken cancellationToken) + { + var parentFolderId = parentFolder.Id; + var isNew = false; + var forceUpdate = false; + + var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); + + if (_libraryManager.GetItemById(id) is not LiveTvChannel item) + { + item = new LiveTvChannel + { + Name = channelInfo.Name, + Id = id, + DateCreated = DateTime.UtcNow + }; + + isNew = true; + } + + if (channelInfo.Tags is not null) + { + if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) + { + isNew = true; + } + + item.Tags = channelInfo.Tags; + } + + if (!item.ParentId.Equals(parentFolderId)) + { + isNew = true; + } + + item.ParentId = parentFolderId; + + item.ChannelType = channelInfo.ChannelType; + item.ServiceName = serviceName; + + if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) + { + forceUpdate = true; + } + + item.SetProviderId(ExternalServiceTag, serviceName); + + if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalId = channelInfo.Id; + + if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.Number = channelInfo.Number; + + if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.Name = channelInfo.Name; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); + forceUpdate = true; + } + else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); + forceUpdate = true; + } + } + + if (isNew) + { + _libraryManager.CreateItem(item, parentFolder); + } + else if (forceUpdate) + { + await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + + return item; + } + + private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram( + ProgramInfo info, + Dictionary allExistingPrograms, + LiveTvChannel channel) + { + var id = _tvDtoService.GetInternalProgramId(info.Id); + + var isNew = false; + var forceUpdate = false; + + if (!allExistingPrograms.TryGetValue(id, out var item)) + { + isNew = true; + item = new LiveTvProgram + { + Name = info.Name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + if (!string.IsNullOrEmpty(info.Etag)) + { + item.SetProviderId(EtagKey, info.Etag); + } + } + + if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) + { + item.ShowId = info.ShowId; + forceUpdate = true; + } + + var seriesId = info.SeriesId; + + if (!item.ParentId.Equals(channel.Id)) + { + forceUpdate = true; + } + + item.ParentId = channel.Id; + + item.Audio = info.Audio; + item.ChannelId = channel.Id; + item.CommunityRating ??= info.CommunityRating; + if ((item.CommunityRating ?? 0).Equals(0)) + { + item.CommunityRating = null; + } + + item.EpisodeTitle = info.EpisodeTitle; + item.ExternalId = info.Id; + + if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalSeriesId = seriesId; + + var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); + + if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) + { + item.SeriesName = info.Name; + } + + var tags = new List(); + if (info.IsLive) + { + tags.Add("Live"); + } + + if (info.IsPremiere) + { + tags.Add("Premiere"); + } + + if (info.IsNews) + { + tags.Add("News"); + } + + if (info.IsSports) + { + tags.Add("Sports"); + } + + if (info.IsKids) + { + tags.Add("Kids"); + } + + if (info.IsRepeat) + { + tags.Add("Repeat"); + } + + if (info.IsMovie) + { + tags.Add("Movie"); + } + + if (isSeries) + { + tags.Add("Series"); + } + + item.Tags = tags.ToArray(); + + item.Genres = info.Genres.ToArray(); + + if (info.IsHD ?? false) + { + item.Width = 1280; + item.Height = 720; + } + + item.IsMovie = info.IsMovie; + item.IsRepeat = info.IsRepeat; + + if (item.IsSeries != isSeries) + { + forceUpdate = true; + } + + item.IsSeries = isSeries; + + item.Name = info.Name; + item.OfficialRating ??= info.OfficialRating; + item.Overview ??= info.Overview; + item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; + item.ProviderIds = info.ProviderIds; + + foreach (var providerId in info.SeriesProviderIds) + { + info.ProviderIds["Series" + providerId.Key] = providerId.Value; + } + + if (item.StartDate != info.StartDate) + { + forceUpdate = true; + } + + item.StartDate = info.StartDate; + + if (item.EndDate != info.EndDate) + { + forceUpdate = true; + } + + item.EndDate = info.EndDate; + + item.ProductionYear = info.ProductionYear; + + if (!isSeries || info.IsRepeat) + { + item.PremiereDate = info.OriginalAirDate; + } + + item.IndexNumber = info.EpisodeNumber; + item.ParentIndexNumber = info.SeasonNumber; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(info.ImagePath)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ImagePath, + Type = ImageType.Primary + }, + 0); + } + else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ImageUrl, + Type = ImageType.Primary + }, + 0); + } + } + + if (!item.HasImage(ImageType.Thumb)) + { + if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ThumbImageUrl, + Type = ImageType.Thumb + }, + 0); + } + } + + if (!item.HasImage(ImageType.Logo)) + { + if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.LogoImageUrl, + Type = ImageType.Logo + }, + 0); + } + } + + if (!item.HasImage(ImageType.Backdrop)) + { + if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.BackdropImageUrl, + Type = ImageType.Backdrop + }, + 0); + } + } + + var isUpdated = false; + if (isNew) + { + } + else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) + { + isUpdated = true; + } + else + { + var etag = info.Etag; + + if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) + { + item.SetProviderId(EtagKey, etag); + isUpdated = true; + } + } + + if (isNew || isUpdated) + { + item.OnMetadataChanged(); + } + + return (item, isNew, isUpdated); + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 71822f376..e8fb02c4f 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -14,20 +14,16 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; @@ -40,24 +36,16 @@ namespace Jellyfin.LiveTv /// public class LiveTvManager : ILiveTvManager { - private const int MaxGuideDays = 14; - private const string ExternalServiceTag = "ExternalServiceId"; - - private const string EtagKey = "ProgramEtag"; - private readonly IServerConfigurationManager _config; private readonly ILogger _logger; - private readonly IItemRepository _itemRepo; private readonly IUserManager _userManager; private readonly IDtoService _dtoService; private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; private readonly ITaskManager _taskManager; private readonly ILocalizationManager _localization; - private readonly IFileSystem _fileSystem; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; - private readonly ITunerHostManager _tunerHostManager; private ILiveTvService[] _services = Array.Empty(); private IListingsProvider[] _listingProviders = Array.Empty(); @@ -65,31 +53,25 @@ namespace Jellyfin.LiveTv public LiveTvManager( IServerConfigurationManager config, ILogger logger, - IItemRepository itemRepo, IUserDataManager userDataManager, IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, ITaskManager taskManager, ILocalizationManager localization, - IFileSystem fileSystem, IChannelManager channelManager, - LiveTvDtoService liveTvDtoService, - ITunerHostManager tunerHostManager) + LiveTvDtoService liveTvDtoService) { _config = config; _logger = logger; - _itemRepo = itemRepo; _userManager = userManager; _libraryManager = libraryManager; _taskManager = taskManager; _localization = localization; - _fileSystem = fileSystem; _dtoService = dtoService; _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; - _tunerHostManager = tunerHostManager; } public event EventHandler> SeriesTimerCancelled; @@ -400,355 +382,6 @@ namespace Jellyfin.LiveTv } } - private async Task GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) - { - var parentFolderId = parentFolder.Id; - var isNew = false; - var forceUpdate = false; - - var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); - - var item = _libraryManager.GetItemById(id) as LiveTvChannel; - - if (item is null) - { - item = new LiveTvChannel - { - Name = channelInfo.Name, - Id = id, - DateCreated = DateTime.UtcNow - }; - - isNew = true; - } - - if (channelInfo.Tags is not null) - { - if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) - { - isNew = true; - } - - item.Tags = channelInfo.Tags; - } - - if (!item.ParentId.Equals(parentFolderId)) - { - isNew = true; - } - - item.ParentId = parentFolderId; - - item.ChannelType = channelInfo.ChannelType; - item.ServiceName = serviceName; - - if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) - { - forceUpdate = true; - } - - item.SetProviderId(ExternalServiceTag, serviceName); - - if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalId = channelInfo.Id; - - if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.Number = channelInfo.Number; - - if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.Name = channelInfo.Name; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } - } - - if (isNew) - { - _libraryManager.CreateItem(item, parentFolder); - } - else if (forceUpdate) - { - await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - } - - return item; - } - - private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary allExistingPrograms, LiveTvChannel channel) - { - var id = _tvDtoService.GetInternalProgramId(info.Id); - - var isNew = false; - var forceUpdate = false; - - if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item)) - { - isNew = true; - item = new LiveTvProgram - { - Name = info.Name, - Id = id, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow - }; - - if (!string.IsNullOrEmpty(info.Etag)) - { - item.SetProviderId(EtagKey, info.Etag); - } - } - - if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) - { - item.ShowId = info.ShowId; - forceUpdate = true; - } - - var seriesId = info.SeriesId; - - if (!item.ParentId.Equals(channel.Id)) - { - forceUpdate = true; - } - - item.ParentId = channel.Id; - - item.Audio = info.Audio; - item.ChannelId = channel.Id; - item.CommunityRating ??= info.CommunityRating; - if ((item.CommunityRating ?? 0).Equals(0)) - { - item.CommunityRating = null; - } - - item.EpisodeTitle = info.EpisodeTitle; - item.ExternalId = info.Id; - - if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalSeriesId = seriesId; - - var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); - - if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) - { - item.SeriesName = info.Name; - } - - var tags = new List(); - if (info.IsLive) - { - tags.Add("Live"); - } - - if (info.IsPremiere) - { - tags.Add("Premiere"); - } - - if (info.IsNews) - { - tags.Add("News"); - } - - if (info.IsSports) - { - tags.Add("Sports"); - } - - if (info.IsKids) - { - tags.Add("Kids"); - } - - if (info.IsRepeat) - { - tags.Add("Repeat"); - } - - if (info.IsMovie) - { - tags.Add("Movie"); - } - - if (isSeries) - { - tags.Add("Series"); - } - - item.Tags = tags.ToArray(); - - item.Genres = info.Genres.ToArray(); - - if (info.IsHD ?? false) - { - item.Width = 1280; - item.Height = 720; - } - - item.IsMovie = info.IsMovie; - item.IsRepeat = info.IsRepeat; - - if (item.IsSeries != isSeries) - { - forceUpdate = true; - } - - item.IsSeries = isSeries; - - item.Name = info.Name; - item.OfficialRating ??= info.OfficialRating; - item.Overview ??= info.Overview; - item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; - item.ProviderIds = info.ProviderIds; - - foreach (var providerId in info.SeriesProviderIds) - { - info.ProviderIds["Series" + providerId.Key] = providerId.Value; - } - - if (item.StartDate != info.StartDate) - { - forceUpdate = true; - } - - item.StartDate = info.StartDate; - - if (item.EndDate != info.EndDate) - { - forceUpdate = true; - } - - item.EndDate = info.EndDate; - - item.ProductionYear = info.ProductionYear; - - if (!isSeries || info.IsRepeat) - { - item.PremiereDate = info.OriginalAirDate; - } - - item.IndexNumber = info.EpisodeNumber; - item.ParentIndexNumber = info.SeasonNumber; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(info.ImagePath)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImagePath, - Type = ImageType.Primary - }, - 0); - } - else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImageUrl, - Type = ImageType.Primary - }, - 0); - } - } - - if (!item.HasImage(ImageType.Thumb)) - { - if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ThumbImageUrl, - Type = ImageType.Thumb - }, - 0); - } - } - - if (!item.HasImage(ImageType.Logo)) - { - if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.LogoImageUrl, - Type = ImageType.Logo - }, - 0); - } - } - - if (!item.HasImage(ImageType.Backdrop)) - { - if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.BackdropImageUrl, - Type = ImageType.Backdrop - }, - 0); - } - } - - var isUpdated = false; - if (isNew) - { - } - else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) - { - isUpdated = true; - } - else - { - var etag = info.Etag; - - if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) - { - item.SetProviderId(EtagKey, etag); - isUpdated = true; - } - } - - if (isNew || isUpdated) - { - item.OnMetadataChanged(); - } - - return (item, isNew, isUpdated); - } - public async Task GetProgram(string id, CancellationToken cancellationToken, User user = null) { var program = _libraryManager.GetItemById(id); @@ -1000,293 +633,6 @@ namespace Jellyfin.LiveTv } } - internal Task RefreshChannels(IProgress progress, CancellationToken cancellationToken) - { - return RefreshChannelsInternal(progress, cancellationToken); - } - - private async Task RefreshChannelsInternal(IProgress progress, CancellationToken cancellationToken) - { - await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); - - await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); - - var numComplete = 0; - double progressPerService = _services.Length == 0 - ? 0 - : 1.0 / _services.Length; - - var newChannelIdList = new List(); - var newProgramIdList = new List(); - - var cleanDatabase = true; - - foreach (var service in _services) - { - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogDebug("Refreshing guide from {Name}", service.Name); - - try - { - var innerProgress = new ActionableProgress(); - innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); - - var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); - - newChannelIdList.AddRange(idList.Item1); - newProgramIdList.AddRange(idList.Item2); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - cleanDatabase = false; - _logger.LogError(ex, "Error refreshing channels for service"); - } - - numComplete++; - double percent = numComplete; - percent /= _services.Length; - - progress.Report(100 * percent); - } - - if (cleanDatabase) - { - CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken); - CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken); - } - - var coreService = _services.OfType().FirstOrDefault(); - - if (coreService is not null) - { - await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); - await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); - } - - // Load these now which will prefetch metadata - var dtoOptions = new DtoOptions(); - var fields = dtoOptions.Fields.ToList(); - dtoOptions.Fields = fields.ToArray(); - - progress.Report(100); - } - - private async Task, List>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress progress, CancellationToken cancellationToken) - { - progress.Report(10); - - var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) - .Select(i => new Tuple(service.Name, i)) - .ToList(); - - var list = new List(); - - var numComplete = 0; - var parentFolder = GetInternalLiveTvFolder(cancellationToken); - - foreach (var channelInfo in allChannelsList) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); - - list.Add(item); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); - } - - numComplete++; - double percent = numComplete; - percent /= allChannelsList.Count; - - progress.Report((5 * percent) + 10); - } - - progress.Report(15); - - numComplete = 0; - var programs = new List(); - var channels = new List(); - - var guideDays = GetGuideDays(); - - _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); - - cancellationToken.ThrowIfCancellationRequested(); - - foreach (var currentChannel in list) - { - channels.Add(currentChannel.Id); - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var start = DateTime.UtcNow.AddHours(-1); - var end = start.AddDays(guideDays); - - var isMovie = false; - var isSports = false; - var isNews = false; - var isKids = false; - var iSSeries = false; - - var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); - - var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ChannelIds = new Guid[] { currentChannel.Id }, - DtoOptions = new DtoOptions(true) - }).Cast().ToDictionary(i => i.Id); - - var newPrograms = new List(); - var updatedPrograms = new List(); - - foreach (var program in channelPrograms) - { - var programTuple = GetProgram(program, existingPrograms, currentChannel); - var programItem = programTuple.Item; - - if (programTuple.IsNew) - { - newPrograms.Add(programItem); - } - else if (programTuple.IsUpdated) - { - updatedPrograms.Add(programItem); - } - - programs.Add(programItem.Id); - - isMovie |= program.IsMovie; - iSSeries |= program.IsSeries; - isSports |= program.IsSports; - isNews |= program.IsNews; - isKids |= program.IsKids; - } - - _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); - - if (newPrograms.Count > 0) - { - _libraryManager.CreateItems(newPrograms, null, cancellationToken); - } - - if (updatedPrograms.Count > 0) - { - await _libraryManager.UpdateItemsAsync( - updatedPrograms, - currentChannel, - ItemUpdateType.MetadataImport, - cancellationToken).ConfigureAwait(false); - } - - currentChannel.IsMovie = isMovie; - currentChannel.IsNews = isNews; - currentChannel.IsSports = isSports; - currentChannel.IsSeries = iSSeries; - - if (isKids) - { - currentChannel.AddTag("Kids"); - } - - await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - await currentChannel.RefreshMetadata( - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ForceSave = true - }, - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); - } - - numComplete++; - double percent = numComplete / (double)allChannelsList.Count; - - progress.Report((85 * percent) + 15); - } - - progress.Report(100); - return new Tuple, List>(channels, programs); - } - - private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress progress, CancellationToken cancellationToken) - { - var list = _itemRepo.GetItemIdsList(new InternalItemsQuery - { - IncludeItemTypes = validTypes, - DtoOptions = new DtoOptions(false) - }); - - var numComplete = 0; - - foreach (var itemId in list) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (itemId.Equals(default)) - { - // Somehow some invalid data got into the db. It probably predates the boundary checking - continue; - } - - if (!currentIdList.Contains(itemId)) - { - var item = _libraryManager.GetItemById(itemId); - - if (item is not null) - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false, - DeleteFromExternalProvider = false - }, - false); - } - } - - numComplete++; - double percent = numComplete / (double)list.Count; - - progress.Report(100 * percent); - } - } - - private double GetGuideDays() - { - var config = _config.GetLiveTvConfiguration(); - - if (config.GuideDays.HasValue) - { - return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)); - } - - return 7; - } - private async Task> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) { if (user is null) @@ -2056,18 +1402,6 @@ namespace Jellyfin.LiveTv await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false); } - public GuideInfo GetGuideInfo() - { - var startDate = DateTime.UtcNow; - var endDate = startDate.AddDays(GetGuideDays()); - - return new GuideInfo - { - StartDate = startDate, - EndDate = endDate - }; - } - private LiveTvServiceInfo[] GetServiceInfos() { return Services.Select(GetServiceInfo).ToArray(); diff --git a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs index 18bd61d99..798ababc2 100644 --- a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs +++ b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs @@ -15,16 +15,22 @@ namespace Jellyfin.LiveTv public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask { private readonly ILiveTvManager _liveTvManager; + private readonly IGuideManager _guideManager; private readonly IConfigurationManager _config; /// /// Initializes a new instance of the class. /// /// The live tv manager. + /// The guide manager. /// The configuration manager. - public RefreshGuideScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) + public RefreshGuideScheduledTask( + ILiveTvManager liveTvManager, + IGuideManager guideManager, + IConfigurationManager config) { _liveTvManager = liveTvManager; + _guideManager = guideManager; _config = config; } @@ -51,11 +57,7 @@ namespace Jellyfin.LiveTv /// public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) - { - var manager = (LiveTvManager)_liveTvManager; - - return manager.RefreshChannels(progress, cancellationToken); - } + => _guideManager.RefreshGuide(progress, cancellationToken); /// public IEnumerable GetDefaultTriggers() -- cgit v1.2.3 From 3e32f94fb3ab8f817a74e7dd27981174869a0c45 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 09:57:38 -0500 Subject: Move RefreshGuideScheduledTask to Guide folder --- .../Guide/RefreshGuideScheduledTask.cs | 71 +++++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 1 + src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs | 72 ---------------------- src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs | 1 + 4 files changed, 73 insertions(+), 72 deletions(-) create mode 100644 src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs delete mode 100644 src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs new file mode 100644 index 000000000..1c79d6ab3 --- /dev/null +++ b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Tasks; + +namespace Jellyfin.LiveTv.Guide; + +/// +/// The "Refresh Guide" scheduled task. +/// +public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask +{ + private readonly ILiveTvManager _liveTvManager; + private readonly IGuideManager _guideManager; + private readonly IConfigurationManager _config; + + /// + /// Initializes a new instance of the class. + /// + /// The live tv manager. + /// The guide manager. + /// The configuration manager. + public RefreshGuideScheduledTask( + ILiveTvManager liveTvManager, + IGuideManager guideManager, + IConfigurationManager config) + { + _liveTvManager = liveTvManager; + _guideManager = guideManager; + _config = config; + } + + /// + public string Name => "Refresh Guide"; + + /// + public string Description => "Downloads channel information from live tv services."; + + /// + public string Category => "Live TV"; + + /// + public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0; + + /// + public bool IsEnabled => true; + + /// + public bool IsLogged => true; + + /// + public string Key => "RefreshGuide"; + + /// + public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + => _guideManager.RefreshGuide(progress, cancellationToken); + + /// + public IEnumerable GetDefaultTriggers() + { + return new[] + { + // Every so often + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + }; + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index e8fb02c4f..aa3be2048 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -13,6 +13,7 @@ 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; diff --git a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs deleted file mode 100644 index 798ababc2..000000000 --- a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.LiveTv.Configuration; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Tasks; - -namespace Jellyfin.LiveTv -{ - /// - /// The "Refresh Guide" scheduled task. - /// - public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask - { - private readonly ILiveTvManager _liveTvManager; - private readonly IGuideManager _guideManager; - private readonly IConfigurationManager _config; - - /// - /// Initializes a new instance of the class. - /// - /// The live tv manager. - /// The guide manager. - /// The configuration manager. - public RefreshGuideScheduledTask( - ILiveTvManager liveTvManager, - IGuideManager guideManager, - IConfigurationManager config) - { - _liveTvManager = liveTvManager; - _guideManager = guideManager; - _config = config; - } - - /// - public string Name => "Refresh Guide"; - - /// - public string Description => "Downloads channel information from live tv services."; - - /// - public string Category => "Live TV"; - - /// - public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0; - - /// - public bool IsEnabled => true; - - /// - public bool IsLogged => true; - - /// - public string Key => "RefreshGuide"; - - /// - public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) - => _guideManager.RefreshGuide(progress, cancellationToken); - - /// - public IEnumerable GetDefaultTriggers() - { - return new[] - { - // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } - }; - } - } -} diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs index 3e4b0e13f..60be19c68 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Guide; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.LiveTv; -- cgit v1.2.3 From 502cbe77b2658cbca1a9f25d5e5e78ad4cd63eab Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:10:09 -0500 Subject: Use Math.Clamp in GetGuideDays --- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index ae0fdb07a..18831aa4e 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -159,7 +159,7 @@ public class GuideManager : IGuideManager var config = _config.GetLiveTvConfiguration(); return config.GuideDays.HasValue - ? Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)) + ? Math.Clamp(config.GuideDays.Value, 1, MaxGuideDays) : 7; } -- cgit v1.2.3 From 5d3acd43e9aeaf8e050a8c917192d8288725804d Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:10:42 -0500 Subject: Use collection expression --- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 18831aa4e..f157af5ea 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -233,7 +233,7 @@ public class GuideManager : IGuideManager var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.LiveTvProgram], - ChannelIds = new[] { currentChannel.Id }, + ChannelIds = [currentChannel.Id], DtoOptions = new DtoOptions(true) }).Cast().ToDictionary(i => i.Id); -- cgit v1.2.3 From 75c2de110e3d67ac1f9adc684fc26b066a1915ce Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:12:24 -0500 Subject: Remove useless comment --- src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs index 1c79d6ab3..a9fde0850 100644 --- a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs @@ -64,8 +64,11 @@ public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledT { return new[] { - // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(24).Ticks + } }; } } -- cgit v1.2.3 From f0a9639c173a8ade72b0e1de4345c7409da1b78f Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:14:28 -0500 Subject: Remove pointless code --- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 5 ----- 1 file changed, 5 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index f157af5ea..bfbc6d4cc 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -146,11 +146,6 @@ public class GuideManager : IGuideManager await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); } - // Load these now which will prefetch metadata - var dtoOptions = new DtoOptions(); - var fields = dtoOptions.Fields.ToList(); - dtoOptions.Fields = fields.ToArray(); - progress.Report(100); } -- cgit v1.2.3 From 604f4b2742416abf3149e95d8168b538ecb8b5f1 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 24 Jan 2024 11:17:45 -0500 Subject: Log SchedulesDirect response on request error --- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 5728146f7..eaf5495c7 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -608,6 +608,11 @@ namespace Jellyfin.LiveTv.Listings if (!enableRetry || (int)response.StatusCode >= 500) { + _logger.LogError( + "Request to {Url} failed with response {Response}", + message.RequestUri, + await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + throw new HttpRequestException( string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), null, @@ -655,11 +660,22 @@ namespace Jellyfin.LiveTv.Listings ArgumentException.ThrowIfNullOrEmpty(token); ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); - _logger.LogInformation("Adding new LineUp "); + _logger.LogInformation("Adding new lineup {Id}", info.ListingsId); - using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); - options.Headers.TryAddWithoutValidation("token", token); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); + message.Headers.TryAddWithoutValidation("token", token); + + using var client = _httpClientFactory.CreateClient(NamedClient.Default); + using var response = await client + .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "Error adding lineup to account: {Response}", + await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + } } private async Task HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) -- 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 'src') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 5870fed76..84189f7f5 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 2dbc2cf82..69daa5c20 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 21dab69e0..eb97ef3ee 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 aa3be2048..1595c8553 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 775b7eadef0e36f88f9b4424ac3cd924406b38ca Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:26:47 -0500 Subject: Kill circular dependency between LiveTvManager and EmbyTV --- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 29 +++++++++++++++++++---------- src/Jellyfin.LiveTv/LiveTvManager.cs | 16 ---------------- 2 files changed, 19 insertions(+), 26 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index e7e927b2d..ce55d7427 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -51,7 +51,6 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly ItemDataProvider _seriesTimerProvider; private readonly TimerManager _timerProvider; - private readonly LiveTvManager _liveTvManager; private readonly ITunerHostManager _tunerHostManager; private readonly IFileSystem _fileSystem; @@ -61,6 +60,8 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; private readonly IStreamHelper _streamHelper; + private readonly LiveTvDtoService _tvDtoService; + private readonly IListingsProvider[] _listingsProviders; private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); @@ -78,13 +79,14 @@ namespace Jellyfin.LiveTv.EmbyTV ILogger logger, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, - ILiveTvManager liveTvManager, ITunerHostManager tunerHostManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, - IMediaEncoder mediaEncoder) + IMediaEncoder mediaEncoder, + LiveTvDtoService tvDtoService, + IEnumerable listingsProviders) { Current = this; @@ -96,10 +98,11 @@ namespace Jellyfin.LiveTv.EmbyTV _libraryMonitor = libraryMonitor; _providerManager = providerManager; _mediaEncoder = mediaEncoder; - _liveTvManager = (LiveTvManager)liveTvManager; + _tvDtoService = tvDtoService; _tunerHostManager = tunerHostManager; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; + _listingsProviders = listingsProviders.ToArray(); _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); @@ -937,7 +940,7 @@ namespace Jellyfin.LiveTv.EmbyTV return _config.GetLiveTvConfiguration().ListingProviders .Select(i => { - var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + var provider = _listingsProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); return provider is null ? null : new Tuple(provider, i); }) @@ -1181,6 +1184,12 @@ namespace Jellyfin.LiveTv.EmbyTV return Path.Combine(recordPath, recordingFileName); } + private BaseItem GetLiveTvChannel(TimerInfo timer) + { + var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId); + return _libraryManager.GetItemById(internalChannelId); + } + private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) { ArgumentNullException.ThrowIfNull(timer); @@ -1206,7 +1215,7 @@ namespace Jellyfin.LiveTv.EmbyTV var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); - var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); + var channelItem = GetLiveTvChannel(timer); string liveStreamId = null; RecordingStatus recordingStatus; @@ -2089,7 +2098,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var query = new InternalItemsQuery { - ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) }, + ItemIds = [_tvDtoService.GetInternalProgramId(programId)], Limit = 1, DtoOptions = new DtoOptions() }; @@ -2119,7 +2128,7 @@ namespace Jellyfin.LiveTv.EmbyTV if (!string.IsNullOrWhiteSpace(channelId)) { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) }; + query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)]; } return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); @@ -2155,7 +2164,7 @@ namespace Jellyfin.LiveTv.EmbyTV private void HandleDuplicateShowIds(List timers) { // sort showings by HD channels first, then by startDate, record earliest showing possible - foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) + foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1)) { timer.Status = RecordingStatus.Cancelled; _timerProvider.Update(timer); @@ -2305,7 +2314,7 @@ namespace Jellyfin.LiveTv.EmbyTV if (!seriesTimer.RecordAnyChannel) { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) }; + query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)]; } var tempChannelCache = new Dictionary(); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 1595c8553..19a71a119 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -1165,12 +1165,6 @@ namespace Jellyfin.LiveTv return new QueryResult(returnArray); } - public BaseItem GetLiveTvChannel(TimerInfo timer, ILiveTvService service) - { - var internalChannelId = _tvDtoService.GetInternalChannelId(service.Name, timer.ChannelId); - return _libraryManager.GetItemById(internalChannelId); - } - public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user) { var now = DateTime.UtcNow; @@ -1636,16 +1630,6 @@ namespace Jellyfin.LiveTv return provider.GetChannels(info, cancellationToken); } - public Guid GetInternalChannelId(string serviceName, string externalId) - { - return _tvDtoService.GetInternalChannelId(serviceName, externalId); - } - - public Guid GetInternalProgramId(string externalId) - { - return _tvDtoService.GetInternalProgramId(externalId); - } - /// public Task GetRecordingFoldersAsync(User user) => GetRecordingFoldersAsync(user, false); -- 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 'src') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 84189f7f5..d268a6ba8 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 69daa5c20..7da455b8d 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 eb97ef3ee..a07325ad1 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 19a71a119..ef5283b98 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 584636bdd8ea95d56b3c1cda97ce6efa8ce1543c Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 09:37:02 -0500 Subject: Don't dispose HttpClients --- src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index eaf5495c7..64b64c0ae 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -599,8 +599,9 @@ namespace Jellyfin.LiveTv.Listings CancellationToken cancellationToken, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { - using var client = _httpClientFactory.CreateClient(NamedClient.Default); - using var response = await client.SendAsync(message, completionOption, cancellationToken).ConfigureAwait(false); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(message, completionOption, cancellationToken) + .ConfigureAwait(false); if (response.IsSuccessStatusCode) { return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); @@ -665,8 +666,7 @@ namespace Jellyfin.LiveTv.Listings using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); message.Headers.TryAddWithoutValidation("token", token); - using var client = _httpClientFactory.CreateClient(NamedClient.Default); - using var response = await client + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); -- cgit v1.2.3 From 8698b905947860ed59db1634e3765d78217d362d Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 09:50:46 -0500 Subject: Remove SimpleProgress --- Emby.Server.Implementations/Library/LibraryManager.cs | 6 +++--- .../ScheduledTasks/ScheduledTaskWorker.cs | 3 +-- Jellyfin.Api/Controllers/LibraryController.cs | 4 +--- Jellyfin.Api/Controllers/LibraryStructureController.cs | 8 +++----- MediaBrowser.Common/Progress/SimpleProgress.cs | 17 ----------------- MediaBrowser.Controller/Channels/Channel.cs | 3 +-- MediaBrowser.Controller/Entities/Folder.cs | 2 +- MediaBrowser.Providers/Manager/ProviderManager.cs | 7 +++---- src/Jellyfin.LiveTv/Channels/ChannelManager.cs | 7 +++---- .../Channels/RefreshChannelsScheduledTask.cs | 3 +-- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 3 +-- 11 files changed, 18 insertions(+), 45 deletions(-) delete mode 100644 MediaBrowser.Common/Progress/SimpleProgress.cs (limited to 'src') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 8ae913dad..851581a4a 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1022,7 +1022,7 @@ namespace Emby.Server.Implementations.Library // Start by just validating the children of the root, but go no further await RootFolder.ValidateChildren( - new SimpleProgress(), + new Progress(), new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: false, cancellationToken).ConfigureAwait(false); @@ -1030,7 +1030,7 @@ namespace Emby.Server.Implementations.Library await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false); await GetUserRootFolder().ValidateChildren( - new SimpleProgress(), + new Progress(), new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: false, cancellationToken).ConfigureAwait(false); @@ -2954,7 +2954,7 @@ namespace Emby.Server.Implementations.Library Task.Run(() => { // No need to start if scanning the library because it will handle it - ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None); + ValidateMediaLibrary(new Progress(), CancellationToken.None); }); } diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index 1af2c96d2..efb6436ae 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -14,7 +14,6 @@ using Jellyfin.Data.Events; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -371,7 +370,7 @@ namespace Emby.Server.Implementations.ScheduledTasks throw new InvalidOperationException("Cannot execute a Task that is already running"); } - var progress = new SimpleProgress(); + var progress = new Progress(); CurrentCancellationTokenSource = new CancellationTokenSource(); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index a0bbc961f..e357588d1 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -17,7 +16,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -313,7 +311,7 @@ public class LibraryController : BaseJellyfinApiController { try { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index d483ca4d2..23c430f85 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -6,11 +6,9 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; using MediaBrowser.Common.Api; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -180,7 +178,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); } else { @@ -224,7 +222,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); } else { @@ -293,7 +291,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); } else { diff --git a/MediaBrowser.Common/Progress/SimpleProgress.cs b/MediaBrowser.Common/Progress/SimpleProgress.cs deleted file mode 100644 index 7071f2bc3..000000000 --- a/MediaBrowser.Common/Progress/SimpleProgress.cs +++ /dev/null @@ -1,17 +0,0 @@ -#pragma warning disable CS1591 -#pragma warning disable CA1003 - -using System; - -namespace MediaBrowser.Common.Progress -{ - public class SimpleProgress : IProgress - { - public event EventHandler? ProgressChanged; - - public void Report(T value) - { - ProgressChanged?.Invoke(this, value); - } - } -} diff --git a/MediaBrowser.Controller/Channels/Channel.cs b/MediaBrowser.Controller/Channels/Channel.cs index 94418683b..f186523b9 100644 --- a/MediaBrowser.Controller/Channels/Channel.cs +++ b/MediaBrowser.Controller/Channels/Channel.cs @@ -9,7 +9,6 @@ using System.Text.Json.Serialization; using System.Threading; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Querying; @@ -53,7 +52,7 @@ namespace MediaBrowser.Controller.Channels query.ChannelIds = new Guid[] { Id }; // Don't blow up here because it could cause parent screens with other content to fail - return ChannelManager.GetChannelItemsInternal(query, new SimpleProgress(), CancellationToken.None).GetAwaiter().GetResult(); + return ChannelManager.GetChannelItemsInternal(query, new Progress(), CancellationToken.None).GetAwaiter().GetResult(); } catch { diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 74eb089de..4f066d415 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -922,7 +922,7 @@ namespace MediaBrowser.Controller.Entities query.ChannelIds = new[] { ChannelId }; // Don't blow up here because it could cause parent screens with other content to fail - return ChannelManager.GetChannelItemsInternal(query, new SimpleProgress(), CancellationToken.None).GetAwaiter().GetResult(); + return ChannelManager.GetChannelItemsInternal(query, new Progress(), CancellationToken.None).GetAwaiter().GetResult(); } catch { diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index b530b9de3..2e9547bf3 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -13,7 +13,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.BaseItemManager; using MediaBrowser.Controller.Configuration; @@ -1025,7 +1024,7 @@ namespace MediaBrowser.Providers.Manager await RefreshCollectionFolderChildren(options, collectionFolder, cancellationToken).ConfigureAwait(false); break; case Folder folder: - await folder.ValidateChildren(new SimpleProgress(), options, cancellationToken: cancellationToken).ConfigureAwait(false); + await folder.ValidateChildren(new Progress(), options, cancellationToken: cancellationToken).ConfigureAwait(false); break; } } @@ -1036,7 +1035,7 @@ namespace MediaBrowser.Providers.Manager { await child.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); - await child.ValidateChildren(new SimpleProgress(), options, cancellationToken: cancellationToken).ConfigureAwait(false); + await child.ValidateChildren(new Progress(), options, cancellationToken: cancellationToken).ConfigureAwait(false); } } @@ -1058,7 +1057,7 @@ namespace MediaBrowser.Providers.Manager .Select(i => i.MusicArtist) .Where(i => i is not null); - var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new SimpleProgress(), options, true, cancellationToken)); + var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new Progress(), options, true, cancellationToken)); await Task.WhenAll(musicArtistRefreshTasks).ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index a7de5c65b..1948a9ab9 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -14,7 +14,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -668,7 +667,7 @@ namespace Jellyfin.LiveTv.Channels ChannelIds = new Guid[] { internalChannel.Id } }; - var result = await GetChannelItemsInternal(query, new SimpleProgress(), cancellationToken).ConfigureAwait(false); + var result = await GetChannelItemsInternal(query, new Progress(), cancellationToken).ConfigureAwait(false); foreach (var item in result.Items) { @@ -681,7 +680,7 @@ namespace Jellyfin.LiveTv.Channels EnableTotalRecordCount = false, ChannelIds = new Guid[] { internalChannel.Id } }, - new SimpleProgress(), + new Progress(), cancellationToken).ConfigureAwait(false); } } @@ -763,7 +762,7 @@ namespace Jellyfin.LiveTv.Channels /// public async Task> GetChannelItems(InternalItemsQuery query, CancellationToken cancellationToken) { - var internalResult = await GetChannelItemsInternal(query, new SimpleProgress(), cancellationToken).ConfigureAwait(false); + var internalResult = await GetChannelItemsInternal(query, new Progress(), cancellationToken).ConfigureAwait(false); var returnItems = _dtoService.GetBaseItemDtos(internalResult.Items, query.DtoOptions, query.User); diff --git a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs index 556e052d4..79c5873d5 100644 --- a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; @@ -66,7 +65,7 @@ namespace Jellyfin.LiveTv.Channels { var manager = (ChannelManager)_channelManager; - await manager.RefreshChannels(new SimpleProgress(), cancellationToken).ConfigureAwait(false); + await manager.RefreshChannels(new Progress(), cancellationToken).ConfigureAwait(false); await new ChannelPostScanTask(_channelManager, _logger, _libraryManager).Run(progress, cancellationToken) .ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index a9642bb60..39f334184 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -21,7 +21,6 @@ using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -261,7 +260,7 @@ namespace Jellyfin.LiveTv.EmbyTV if (requiresRefresh) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); } } -- cgit v1.2.3 From 096043806581d305f1d56cf265183e70e2c81e49 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 09:58:25 -0500 Subject: Remove ActionableProgress --- .../Library/LibraryManager.cs | 13 ++------ MediaBrowser.Common/Progress/ActionableProgress.cs | 37 ---------------------- MediaBrowser.Controller/Entities/Folder.cs | 13 ++------ src/Jellyfin.LiveTv/Guide/GuideManager.cs | 6 ++-- 4 files changed, 8 insertions(+), 61 deletions(-) delete mode 100644 MediaBrowser.Common/Progress/ActionableProgress.cs (limited to 'src') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 851581a4a..7998ce34a 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -22,7 +22,6 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; @@ -1048,18 +1047,14 @@ namespace Emby.Server.Implementations.Library await ValidateTopLibraryFolders(cancellationToken).ConfigureAwait(false); - var innerProgress = new ActionableProgress(); - - innerProgress.RegisterAction(pct => progress.Report(pct * 0.96)); + var innerProgress = new Progress(pct => progress.Report(pct * 0.96)); // Validate the entire media library await RootFolder.ValidateChildren(innerProgress, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true, cancellationToken).ConfigureAwait(false); progress.Report(96); - innerProgress = new ActionableProgress(); - - innerProgress.RegisterAction(pct => progress.Report(96 + (pct * .04))); + innerProgress = new Progress(pct => progress.Report(96 + (pct * .04))); await RunPostScanTasks(innerProgress, cancellationToken).ConfigureAwait(false); @@ -1081,12 +1076,10 @@ namespace Emby.Server.Implementations.Library foreach (var task in tasks) { - var innerProgress = new ActionableProgress(); - // Prevent access to modified closure var currentNumComplete = numComplete; - innerProgress.RegisterAction(pct => + var innerProgress = new Progress(pct => { double innerPercent = pct; innerPercent /= 100; diff --git a/MediaBrowser.Common/Progress/ActionableProgress.cs b/MediaBrowser.Common/Progress/ActionableProgress.cs deleted file mode 100644 index 0ba46ea3b..000000000 --- a/MediaBrowser.Common/Progress/ActionableProgress.cs +++ /dev/null @@ -1,37 +0,0 @@ -#pragma warning disable CS1591 -#pragma warning disable CA1003 - -using System; - -namespace MediaBrowser.Common.Progress -{ - /// - /// Class ActionableProgress. - /// - /// The type for the action parameter. - public class ActionableProgress : IProgress - { - /// - /// The _actions. - /// - private Action? _action; - - public event EventHandler? ProgressChanged; - - /// - /// Registers the action. - /// - /// The action. - public void RegisterAction(Action action) - { - _action = action; - } - - public void Report(T value) - { - ProgressChanged?.Invoke(this, value); - - _action?.Invoke(value); - } - } -} diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 4f066d415..e9ff1f1a5 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -13,7 +13,6 @@ using System.Threading.Tasks.Dataflow; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; @@ -429,10 +428,8 @@ namespace MediaBrowser.Controller.Entities if (recursive) { - var innerProgress = new ActionableProgress(); - var folder = this; - innerProgress.RegisterAction(innerPercent => + var innerProgress = new Progress(innerPercent => { var percent = ProgressHelpers.GetProgress(ProgressHelpers.UpdatedChildItems, ProgressHelpers.ScannedSubfolders, innerPercent); @@ -461,10 +458,8 @@ namespace MediaBrowser.Controller.Entities var container = this as IMetadataContainer; - var innerProgress = new ActionableProgress(); - var folder = this; - innerProgress.RegisterAction(innerPercent => + var innerProgress = new Progress(innerPercent => { var percent = ProgressHelpers.GetProgress(ProgressHelpers.ScannedSubfolders, ProgressHelpers.RefreshedMetadata, innerPercent); @@ -572,9 +567,7 @@ namespace MediaBrowser.Controller.Entities var actionBlock = new ActionBlock( async i => { - var innerProgress = new ActionableProgress(); - - innerProgress.RegisterAction(innerPercent => + var innerProgress = new Progress(innerPercent => { // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls var innerPercentRounded = Math.Round(innerPercent); diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index bfbc6d4cc..394fbbaea 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -7,7 +7,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -108,8 +107,7 @@ public class GuideManager : IGuideManager try { - var innerProgress = new ActionableProgress(); - innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); + var innerProgress = new Progress(p => progress.Report(p * progressPerService)); var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); @@ -158,7 +156,7 @@ public class GuideManager : IGuideManager : 7; } - private async Task, List>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress progress, CancellationToken cancellationToken) + private async Task, List>> RefreshChannelsInternal(ILiveTvService service, IProgress progress, CancellationToken cancellationToken) { progress.Report(10); -- cgit v1.2.3 From 4dd2ed8fb7bbd825995e7ec4aead11d6d9728a19 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 10:09:51 -0500 Subject: Remove some unused drawing code --- MediaBrowser.Controller/Drawing/ImageStream.cs | 42 ----------------------- src/Jellyfin.Drawing.Skia/SkiaCodecException.cs | 44 ------------------------- src/Jellyfin.Drawing.Skia/SkiaEncoder.cs | 1 - src/Jellyfin.Drawing.Skia/SkiaException.cs | 38 --------------------- 4 files changed, 125 deletions(-) delete mode 100644 MediaBrowser.Controller/Drawing/ImageStream.cs delete mode 100644 src/Jellyfin.Drawing.Skia/SkiaCodecException.cs delete mode 100644 src/Jellyfin.Drawing.Skia/SkiaException.cs (limited to 'src') diff --git a/MediaBrowser.Controller/Drawing/ImageStream.cs b/MediaBrowser.Controller/Drawing/ImageStream.cs deleted file mode 100644 index f4c305799..000000000 --- a/MediaBrowser.Controller/Drawing/ImageStream.cs +++ /dev/null @@ -1,42 +0,0 @@ -#pragma warning disable CA1711, CS1591 - -using System; -using System.IO; -using MediaBrowser.Model.Drawing; - -namespace MediaBrowser.Controller.Drawing -{ - public class ImageStream : IDisposable - { - public ImageStream(Stream stream) - { - Stream = stream; - } - - /// - /// Gets the stream. - /// - /// The stream. - public Stream Stream { get; } - - /// - /// Gets or sets the format. - /// - /// The format. - public ImageFormat Format { get; set; } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - Stream?.Dispose(); - } - } - } -} diff --git a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs deleted file mode 100644 index 581fa000d..000000000 --- a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Globalization; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia; - -/// -/// Represents errors that occur during interaction with Skia codecs. -/// -public class SkiaCodecException : SkiaException -{ - /// - /// Initializes a new instance of the class. - /// - /// The non-successful codec result returned by Skia. - public SkiaCodecException(SKCodecResult result) - { - CodecResult = result; - } - - /// - /// Initializes a new instance of the class - /// with a specified error message. - /// - /// The non-successful codec result returned by Skia. - /// The message that describes the error. - public SkiaCodecException(SKCodecResult result, string message) - : base(message) - { - CodecResult = result; - } - - /// - /// Gets the non-successful codec result returned by Skia. - /// - public SKCodecResult CodecResult { get; } - - /// - public override string ToString() - => string.Format( - CultureInfo.InvariantCulture, - "Non-success codec result: {0}\n{1}", - CodecResult, - base.ToString()); -} diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 5721e2882..4ae5a9a48 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -182,7 +182,6 @@ public class SkiaEncoder : IImageEncoder /// /// The path is null. /// The path is not valid. - /// The file at the specified path could not be used to generate a codec. public string GetImageBlurHash(int xComp, int yComp, string path) { ArgumentException.ThrowIfNullOrEmpty(path); diff --git a/src/Jellyfin.Drawing.Skia/SkiaException.cs b/src/Jellyfin.Drawing.Skia/SkiaException.cs deleted file mode 100644 index d0e69d42c..000000000 --- a/src/Jellyfin.Drawing.Skia/SkiaException.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; - -namespace Jellyfin.Drawing.Skia; - -/// -/// Represents errors that occur during interaction with Skia. -/// -public class SkiaException : Exception -{ - /// - /// Initializes a new instance of the class. - /// - public SkiaException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The message that describes the error. - public SkiaException(string message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a - /// reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// - /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if - /// no inner exception is specified. - /// - public SkiaException(string message, Exception innerException) - : base(message, innerException) - { - } -} -- cgit v1.2.3 From 99ea6059c7493ac4ee65980abe631df4969112e9 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 14:45:44 -0500 Subject: Use IHostedService for UPnP port forwarding --- Jellyfin.Server/Startup.cs | 1 + src/Jellyfin.Networking/ExternalPortForwarding.cs | 195 ---------------------- src/Jellyfin.Networking/PortForwardingHost.cs | 192 +++++++++++++++++++++ 3 files changed, 193 insertions(+), 195 deletions(-) delete mode 100644 src/Jellyfin.Networking/ExternalPortForwarding.cs create mode 100644 src/Jellyfin.Networking/PortForwardingHost.cs (limited to 'src') diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 7d5f22545..7cf7d75da 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -125,6 +125,7 @@ namespace Jellyfin.Server services.AddLiveTvServices(); services.AddHostedService(); + services.AddHostedService(); } /// diff --git a/src/Jellyfin.Networking/ExternalPortForwarding.cs b/src/Jellyfin.Networking/ExternalPortForwarding.cs deleted file mode 100644 index df9e43ca9..000000000 --- a/src/Jellyfin.Networking/ExternalPortForwarding.cs +++ /dev/null @@ -1,195 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Net; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Plugins; -using Microsoft.Extensions.Logging; -using Mono.Nat; - -namespace Jellyfin.Networking; - -/// -/// Server entrypoint handling external port forwarding. -/// -public sealed class ExternalPortForwarding : IServerEntryPoint -{ - private readonly IServerApplicationHost _appHost; - private readonly ILogger _logger; - private readonly IServerConfigurationManager _config; - - private readonly ConcurrentDictionary _createdRules = new ConcurrentDictionary(); - - private Timer _timer; - private string _configIdentifier; - - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The application host. - /// The configuration manager. - public ExternalPortForwarding( - ILogger logger, - IServerApplicationHost appHost, - IServerConfigurationManager config) - { - _logger = logger; - _appHost = appHost; - _config = config; - } - - private string GetConfigIdentifier() - { - const char Separator = '|'; - var config = _config.GetNetworkConfiguration(); - - return new StringBuilder(32) - .Append(config.EnableUPnP).Append(Separator) - .Append(config.PublicHttpPort).Append(Separator) - .Append(config.PublicHttpsPort).Append(Separator) - .Append(_appHost.HttpPort).Append(Separator) - .Append(_appHost.HttpsPort).Append(Separator) - .Append(_appHost.ListenWithHttps).Append(Separator) - .Append(config.EnableRemoteAccess).Append(Separator) - .ToString(); - } - - private void OnConfigurationUpdated(object sender, EventArgs e) - { - var oldConfigIdentifier = _configIdentifier; - _configIdentifier = GetConfigIdentifier(); - - if (!string.Equals(_configIdentifier, oldConfigIdentifier, StringComparison.OrdinalIgnoreCase)) - { - Stop(); - Start(); - } - } - - /// - public Task RunAsync() - { - Start(); - - _config.ConfigurationUpdated += OnConfigurationUpdated; - - return Task.CompletedTask; - } - - private void Start() - { - var config = _config.GetNetworkConfiguration(); - if (!config.EnableUPnP || !config.EnableRemoteAccess) - { - return; - } - - _logger.LogInformation("Starting NAT discovery"); - - NatUtility.DeviceFound += OnNatUtilityDeviceFound; - NatUtility.StartDiscovery(); - - _timer = new Timer((_) => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); - } - - private void Stop() - { - _logger.LogInformation("Stopping NAT discovery"); - - NatUtility.StopDiscovery(); - NatUtility.DeviceFound -= OnNatUtilityDeviceFound; - - _timer?.Dispose(); - } - - private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e) - { - try - { - await CreateRules(e.Device).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating port forwarding rules"); - } - } - - private Task CreateRules(INatDevice device) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - // On some systems the device discovered event seems to fire repeatedly - // This check will help ensure we're not trying to port map the same device over and over - if (!_createdRules.TryAdd(device.DeviceEndpoint, 0)) - { - return Task.CompletedTask; - } - - return Task.WhenAll(CreatePortMaps(device)); - } - - private IEnumerable CreatePortMaps(INatDevice device) - { - var config = _config.GetNetworkConfiguration(); - yield return CreatePortMap(device, _appHost.HttpPort, config.PublicHttpPort); - - if (_appHost.ListenWithHttps) - { - yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort); - } - } - - private async Task CreatePortMap(INatDevice device, int privatePort, int publicPort) - { - _logger.LogDebug( - "Creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}", - privatePort, - publicPort, - device.DeviceEndpoint); - - try - { - var mapping = new Mapping(Protocol.Tcp, privatePort, publicPort, 0, _appHost.Name); - await device.CreatePortMapAsync(mapping).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Error creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}.", - privatePort, - publicPort, - device.DeviceEndpoint); - } - } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _config.ConfigurationUpdated -= OnConfigurationUpdated; - - Stop(); - - _timer?.Dispose(); - _timer = null; - - _disposed = true; - } -} diff --git a/src/Jellyfin.Networking/PortForwardingHost.cs b/src/Jellyfin.Networking/PortForwardingHost.cs new file mode 100644 index 000000000..d01343624 --- /dev/null +++ b/src/Jellyfin.Networking/PortForwardingHost.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Mono.Nat; + +namespace Jellyfin.Networking; + +/// +/// responsible for UPnP port forwarding. +/// +public sealed class PortForwardingHost : IHostedService, IDisposable +{ + private readonly IServerApplicationHost _appHost; + private readonly ILogger _logger; + private readonly IServerConfigurationManager _config; + private readonly ConcurrentDictionary _createdRules = new(); + + private Timer? _timer; + private string? _configIdentifier; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The application host. + /// The configuration manager. + public PortForwardingHost( + ILogger logger, + IServerApplicationHost appHost, + IServerConfigurationManager config) + { + _logger = logger; + _appHost = appHost; + _config = config; + } + + private string GetConfigIdentifier() + { + const char Separator = '|'; + var config = _config.GetNetworkConfiguration(); + + return new StringBuilder(32) + .Append(config.EnableUPnP).Append(Separator) + .Append(config.PublicHttpPort).Append(Separator) + .Append(config.PublicHttpsPort).Append(Separator) + .Append(_appHost.HttpPort).Append(Separator) + .Append(_appHost.HttpsPort).Append(Separator) + .Append(_appHost.ListenWithHttps).Append(Separator) + .Append(config.EnableRemoteAccess).Append(Separator) + .ToString(); + } + + private void OnConfigurationUpdated(object? sender, EventArgs e) + { + var oldConfigIdentifier = _configIdentifier; + _configIdentifier = GetConfigIdentifier(); + + if (!string.Equals(_configIdentifier, oldConfigIdentifier, StringComparison.OrdinalIgnoreCase)) + { + Stop(); + Start(); + } + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + Start(); + + _config.ConfigurationUpdated += OnConfigurationUpdated; + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + Stop(); + + return Task.CompletedTask; + } + + private void Start() + { + var config = _config.GetNetworkConfiguration(); + if (!config.EnableUPnP || !config.EnableRemoteAccess) + { + return; + } + + _logger.LogInformation("Starting NAT discovery"); + + NatUtility.DeviceFound += OnNatUtilityDeviceFound; + NatUtility.StartDiscovery(); + + _timer?.Dispose(); + _timer = new Timer(_ => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); + } + + private void Stop() + { + _logger.LogInformation("Stopping NAT discovery"); + + NatUtility.StopDiscovery(); + NatUtility.DeviceFound -= OnNatUtilityDeviceFound; + + _timer?.Dispose(); + _timer = null; + } + + private async void OnNatUtilityDeviceFound(object? sender, DeviceEventArgs e) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + try + { + // On some systems the device discovered event seems to fire repeatedly + // This check will help ensure we're not trying to port map the same device over and over + if (!_createdRules.TryAdd(e.Device.DeviceEndpoint, 0)) + { + return; + } + + await Task.WhenAll(CreatePortMaps(e.Device)).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating port forwarding rules"); + } + } + + private IEnumerable CreatePortMaps(INatDevice device) + { + var config = _config.GetNetworkConfiguration(); + yield return CreatePortMap(device, _appHost.HttpPort, config.PublicHttpPort); + + if (_appHost.ListenWithHttps) + { + yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort); + } + } + + private async Task CreatePortMap(INatDevice device, int privatePort, int publicPort) + { + _logger.LogDebug( + "Creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}", + privatePort, + publicPort, + device.DeviceEndpoint); + + try + { + var mapping = new Mapping(Protocol.Tcp, privatePort, publicPort, 0, _appHost.Name); + await device.CreatePortMapAsync(mapping).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}.", + privatePort, + publicPort, + device.DeviceEndpoint); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _config.ConfigurationUpdated -= OnConfigurationUpdated; + + _timer?.Dispose(); + _timer = null; + + _disposed = true; + } +} -- cgit v1.2.3 From 24b4d025967135a8895fedf1c45f3679f3b89393 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:08:41 -0500 Subject: Convert RecordingNotifier to IHostedService --- Jellyfin.Server/Startup.cs | 2 + src/Jellyfin.LiveTv/RecordingNotifier.cs | 73 +++++++++++++++++--------------- 2 files changed, 40 insertions(+), 35 deletions(-) (limited to 'src') diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index b0bb182aa..84f9bff61 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -6,6 +6,7 @@ using System.Net.Mime; using System.Text; using Emby.Server.Implementations.EntryPoints; using Jellyfin.Api.Middleware; +using Jellyfin.LiveTv; using Jellyfin.LiveTv.Extensions; using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking; @@ -129,6 +130,7 @@ namespace Jellyfin.Server services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); } /// diff --git a/src/Jellyfin.LiveTv/RecordingNotifier.cs b/src/Jellyfin.LiveTv/RecordingNotifier.cs index 2923948eb..226d525e7 100644 --- a/src/Jellyfin.LiveTv/RecordingNotifier.cs +++ b/src/Jellyfin.LiveTv/RecordingNotifier.cs @@ -1,7 +1,3 @@ -#nullable disable - -#pragma warning disable CS1591 - using System; using System.Linq; using System.Threading; @@ -10,34 +6,44 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Jellyfin.LiveTv { - public sealed class RecordingNotifier : IServerEntryPoint + /// + /// responsible for notifying users when a LiveTV recording is completed. + /// + public sealed class RecordingNotifier : IHostedService { - private readonly ILiveTvManager _liveTvManager; + private readonly ILogger _logger; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; - private readonly ILogger _logger; + private readonly ILiveTvManager _liveTvManager; + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . public RecordingNotifier( + ILogger logger, ISessionManager sessionManager, IUserManager userManager, - ILogger logger, ILiveTvManager liveTvManager) { + _logger = logger; _sessionManager = sessionManager; _userManager = userManager; - _logger = logger; _liveTvManager = liveTvManager; } /// - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) { _liveTvManager.TimerCancelled += OnLiveTvManagerTimerCancelled; _liveTvManager.SeriesTimerCancelled += OnLiveTvManagerSeriesTimerCancelled; @@ -47,29 +53,35 @@ namespace Jellyfin.LiveTv return Task.CompletedTask; } - private async void OnLiveTvManagerSeriesTimerCreated(object sender, GenericEventArgs e) + /// + public Task StopAsync(CancellationToken cancellationToken) { - await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); - } + _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; + _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; + _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; + _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; - private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs e) - { - await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); + return Task.CompletedTask; } - private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs e) - { - await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); - } + private async void OnLiveTvManagerSeriesTimerCreated(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); - private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs e) - { - await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); - } + private async void OnLiveTvManagerTimerCreated(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerSeriesTimerCancelled(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerTimerCancelled(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); private async Task SendMessage(SessionMessageType name, TimerEventInfo info) { - var users = _userManager.Users.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)).Select(i => i.Id).ToList(); + var users = _userManager.Users + .Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)) + .Select(i => i.Id) + .ToList(); try { @@ -80,14 +92,5 @@ namespace Jellyfin.LiveTv _logger.LogError(ex, "Error sending message"); } } - - /// - public void Dispose() - { - _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; - _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; - _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; - _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; - } } } -- cgit v1.2.3 From c9311c9e7e048eadb1abb62a1904098adb740a76 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:23:18 -0500 Subject: Use IHostedService for Live TV --- Jellyfin.Server/Startup.cs | 2 ++ src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs | 21 --------------------- src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 21 deletions(-) delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs (limited to 'src') diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 1d78b1602..558ad5b7b 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -7,6 +7,7 @@ using System.Text; using Emby.Server.Implementations.EntryPoints; using Jellyfin.Api.Middleware; using Jellyfin.LiveTv; +using Jellyfin.LiveTv.EmbyTV; using Jellyfin.LiveTv.Extensions; using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking; @@ -127,6 +128,7 @@ namespace Jellyfin.Server services.AddHlsPlaylistGenerator(); services.AddLiveTvServices(); + services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); diff --git a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs b/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs deleted file mode 100644 index e750c05ac..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -#pragma warning disable CS1591 - -using System.Threading.Tasks; -using MediaBrowser.Controller.Plugins; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public sealed class EntryPoint : IServerEntryPoint - { - /// - public Task RunAsync() - { - return EmbyTV.Current.Start(); - } - - /// - public void Dispose() - { - } - } -} diff --git a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs new file mode 100644 index 000000000..dc15d53ff --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.LiveTv; +using Microsoft.Extensions.Hosting; + +namespace Jellyfin.LiveTv.EmbyTV; + +/// +/// responsible for initializing Live TV. +/// +public sealed class LiveTvHost : IHostedService +{ + private readonly EmbyTV _service; + + /// + /// Initializes a new instance of the class. + /// + /// The available s. + public LiveTvHost(IEnumerable services) + { + _service = services.OfType().First(); + } + + /// + public Task StartAsync(CancellationToken cancellationToken) => _service.Start(); + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} -- 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 'src') diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index da68c72c9..7f4cad951 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 cbc3548b1..000000000 --- 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 000000000..bbf569575 --- /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 7da455b8d..0ac0699a3 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 1c1a4417d..000000000 --- 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 000000000..3f9ecc8c8 --- /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 000000000..647e24a91 --- /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 39f334184..e19d2c591 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 a07325ad1..e4800a031 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 000000000..113979257 --- /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 ef5283b98..1b69fd7fd 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 1a24d26dace17587d598af0acf343f8d193a618b Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 12:08:41 -0500 Subject: Move EpgChannelData to Listings folder --- src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs | 54 -------------------------- src/Jellyfin.LiveTv/Listings/EpgChannelData.cs | 54 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 54 deletions(-) delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs create mode 100644 src/Jellyfin.LiveTv/Listings/EpgChannelData.cs (limited to 'src') diff --git a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs b/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs deleted file mode 100644 index 43d308c43..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs +++ /dev/null @@ -1,54 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.LiveTv; - -namespace Jellyfin.LiveTv.EmbyTV -{ - internal class EpgChannelData - { - private readonly Dictionary _channelsById; - - private readonly Dictionary _channelsByNumber; - - private readonly Dictionary _channelsByName; - - public EpgChannelData(IEnumerable channels) - { - _channelsById = new Dictionary(StringComparer.OrdinalIgnoreCase); - _channelsByNumber = new Dictionary(StringComparer.OrdinalIgnoreCase); - _channelsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var channel in channels) - { - _channelsById[channel.Id] = channel; - - if (!string.IsNullOrEmpty(channel.Number)) - { - _channelsByNumber[channel.Number] = channel; - } - - var normalizedName = NormalizeName(channel.Name ?? string.Empty); - if (!string.IsNullOrWhiteSpace(normalizedName)) - { - _channelsByName[normalizedName] = channel; - } - } - } - - public ChannelInfo? GetChannelById(string id) - => _channelsById.GetValueOrDefault(id); - - public ChannelInfo? GetChannelByNumber(string number) - => _channelsByNumber.GetValueOrDefault(number); - - public ChannelInfo? GetChannelByName(string name) - => _channelsByName.GetValueOrDefault(name); - - public static string NormalizeName(string value) - { - return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); - } - } -} diff --git a/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs b/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs new file mode 100644 index 000000000..81437f791 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs @@ -0,0 +1,54 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.Listings +{ + internal class EpgChannelData + { + private readonly Dictionary _channelsById; + + private readonly Dictionary _channelsByNumber; + + private readonly Dictionary _channelsByName; + + public EpgChannelData(IEnumerable channels) + { + _channelsById = new Dictionary(StringComparer.OrdinalIgnoreCase); + _channelsByNumber = new Dictionary(StringComparer.OrdinalIgnoreCase); + _channelsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var channel in channels) + { + _channelsById[channel.Id] = channel; + + if (!string.IsNullOrEmpty(channel.Number)) + { + _channelsByNumber[channel.Number] = channel; + } + + var normalizedName = NormalizeName(channel.Name ?? string.Empty); + if (!string.IsNullOrWhiteSpace(normalizedName)) + { + _channelsByName[normalizedName] = channel; + } + } + } + + public ChannelInfo? GetChannelById(string id) + => _channelsById.GetValueOrDefault(id); + + public ChannelInfo? GetChannelByNumber(string number) + => _channelsByNumber.GetValueOrDefault(number); + + public ChannelInfo? GetChannelByName(string name) + => _channelsByName.GetValueOrDefault(name); + + public static string NormalizeName(string value) + { + return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); + } + } +} -- cgit v1.2.3 From 1c11c460b94cc2a71e1fd629e6fb7ba5ac4e2f34 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 15:27:42 -0500 Subject: Use ValueTuple in GetListingsProviders --- src/Jellyfin.LiveTv/Listings/ListingsManager.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index 113979257..9b239f7e4 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -7,7 +7,6 @@ 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; @@ -267,16 +266,13 @@ public class ListingsManager : IListingsManager return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)); } - private List> GetListingProviders() + private List<(IListingsProvider Provider, ListingsProviderInfo ProviderInfo)> 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) + .Select(info => ( + Provider: _listingsProviders.FirstOrDefault(l + => string.Equals(l.Type, info.Type, StringComparison.OrdinalIgnoreCase)), + ProviderInfo: info)) + .Where(i => i.Provider is not null) .ToList()!; // Already filtered out null private async Task AddMetadata( -- cgit v1.2.3 From 3bdaf640ec52e38e64da25fce07631034538c74a Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 15:28:36 -0500 Subject: Remove unnecessary JSON roundtrip in SaveListingProvider --- src/Jellyfin.LiveTv/Listings/ListingsManager.cs | 5 ----- 1 file changed, 5 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index 9b239f7e4..87f47611e 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -3,7 +3,6 @@ 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; @@ -56,10 +55,6 @@ public class ListingsManager : IListingsManager { 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); -- cgit v1.2.3 From dfe82a74720d2592eda1631ee5e7a90292df2903 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 12:05:08 -0500 Subject: Use DI for timer managers --- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 90 +++++++++++----------- src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs | 9 ++- src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs | 32 +++----- .../LiveTvServiceCollectionExtensions.cs | 3 + 4 files changed, 65 insertions(+), 69 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index e19d2c591..a850ad6eb 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -47,21 +47,18 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; - - private readonly ItemDataProvider _seriesTimerProvider; - private readonly TimerManager _timerProvider; - 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 LiveTvDtoService _tvDtoService; private readonly IListingsManager _listingsManager; + private readonly LiveTvDtoService _tvDtoService; + private readonly TimerManager _timerManager; + private readonly ItemDataProvider _seriesTimerManager; private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); @@ -82,8 +79,10 @@ namespace Jellyfin.LiveTv.EmbyTV ILibraryMonitor libraryMonitor, IProviderManager providerManager, IMediaEncoder mediaEncoder, + IListingsManager listingsManager, LiveTvDtoService tvDtoService, - IListingsManager listingsManager) + TimerManager timerManager, + SeriesTimerManager seriesTimerManager) { Current = this; @@ -95,16 +94,15 @@ namespace Jellyfin.LiveTv.EmbyTV _libraryMonitor = libraryMonitor; _providerManager = providerManager; _mediaEncoder = mediaEncoder; - _tvDtoService = tvDtoService; _tunerHostManager = tunerHostManager; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; _listingsManager = listingsManager; + _tvDtoService = tvDtoService; + _timerManager = timerManager; + _seriesTimerManager = seriesTimerManager; - _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); - _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); - _timerProvider.TimerFired += OnTimerProviderTimerFired; - + _timerManager.TimerFired += OnTimerManagerTimerFired; _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; } @@ -146,7 +144,7 @@ namespace Jellyfin.LiveTv.EmbyTV public Task Start() { - _timerProvider.RestartTimers(); + _timerManager.RestartTimers(); return CreateRecordingFolders(); } @@ -298,13 +296,13 @@ namespace Jellyfin.LiveTv.EmbyTV } CopyProgramInfoToTimerInfo(program, timer, tempChannelCache); - _timerProvider.Update(timer); + _timerManager.Update(timer); } } private void OnTimerOutOfDate(TimerInfo timer) { - _timerProvider.Delete(timer); + _timerManager.Delete(timer); } private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) @@ -337,7 +335,7 @@ namespace Jellyfin.LiveTv.EmbyTV public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) { - var timers = _timerProvider + var timers = _timerManager .GetAll() .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) .ToList(); @@ -347,10 +345,10 @@ namespace Jellyfin.LiveTv.EmbyTV CancelTimerInternal(timer.Id, true, true); } - var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); + var remove = _seriesTimerManager.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); if (remove is not null) { - _seriesTimerProvider.Delete(remove); + _seriesTimerManager.Delete(remove); } return Task.CompletedTask; @@ -358,7 +356,7 @@ namespace Jellyfin.LiveTv.EmbyTV private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation) { - var timer = _timerProvider.GetTimer(timerId); + var timer = _timerManager.GetTimer(timerId); if (timer is not null) { var statusChanging = timer.Status != RecordingStatus.Cancelled; @@ -371,11 +369,11 @@ namespace Jellyfin.LiveTv.EmbyTV if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) { - _timerProvider.Delete(timer); + _timerManager.Delete(timer); } else { - _timerProvider.AddOrUpdate(timer, false); + _timerManager.AddOrUpdate(timer, false); } if (statusChanging && TimerCancelled is not null) @@ -411,7 +409,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? null : - _timerProvider.GetTimerByProgramId(info.ProgramId); + _timerManager.GetTimerByProgramId(info.ProgramId); if (existingTimer is not null) { @@ -420,7 +418,7 @@ namespace Jellyfin.LiveTv.EmbyTV { existingTimer.Status = RecordingStatus.New; existingTimer.IsManual = true; - _timerProvider.Update(existingTimer); + _timerManager.Update(existingTimer); return Task.FromResult(existingTimer.Id); } @@ -448,7 +446,7 @@ namespace Jellyfin.LiveTv.EmbyTV } info.IsManual = true; - _timerProvider.Add(info); + _timerManager.Add(info); TimerCreated?.Invoke(this, new GenericEventArgs(info)); @@ -489,14 +487,14 @@ namespace Jellyfin.LiveTv.EmbyTV }) .ToList(); - _seriesTimerProvider.Add(info); + _seriesTimerManager.Add(info); foreach (var timer in existingTimers) { timer.SeriesTimerId = info.Id; timer.IsManual = true; - _timerProvider.AddOrUpdate(timer, false); + _timerManager.AddOrUpdate(timer, false); } UpdateTimersForSeriesTimer(info, true, false); @@ -506,7 +504,7 @@ namespace Jellyfin.LiveTv.EmbyTV public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) { - var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + var instance = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); if (instance is not null) { @@ -526,7 +524,7 @@ namespace Jellyfin.LiveTv.EmbyTV instance.KeepUntil = info.KeepUntil; instance.StartDate = info.StartDate; - _seriesTimerProvider.Update(instance); + _seriesTimerManager.Update(instance); UpdateTimersForSeriesTimer(instance, true, true); } @@ -536,7 +534,7 @@ namespace Jellyfin.LiveTv.EmbyTV public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) { - var existingTimer = _timerProvider.GetTimer(updatedTimer.Id); + var existingTimer = _timerManager.GetTimer(updatedTimer.Id); if (existingTimer is null) { @@ -551,7 +549,7 @@ namespace Jellyfin.LiveTv.EmbyTV existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; - _timerProvider.Update(existingTimer); + _timerManager.Update(existingTimer); } return Task.CompletedTask; @@ -625,7 +623,7 @@ namespace Jellyfin.LiveTv.EmbyTV RecordingStatus.Completed }; - var timers = _timerProvider.GetAll() + var timers = _timerManager.GetAll() .Where(i => !excludeStatues.Contains(i.Status)); return Task.FromResult(timers); @@ -671,7 +669,7 @@ namespace Jellyfin.LiveTv.EmbyTV public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) { - return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); + return Task.FromResult((IEnumerable)_seriesTimerManager.GetAll()); } public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) @@ -766,7 +764,7 @@ namespace Jellyfin.LiveTv.EmbyTV return Task.CompletedTask; } - private async void OnTimerProviderTimerFired(object sender, GenericEventArgs e) + private async void OnTimerManagerTimerFired(object sender, GenericEventArgs e) { var timer = e.Argument; @@ -996,7 +994,7 @@ namespace Jellyfin.LiveTv.EmbyTV _activeRecordings.TryAdd(timer.Id, activeRecordingInfo); timer.Status = RecordingStatus.InProgress; - _timerProvider.AddOrUpdate(timer, false); + _timerManager.AddOrUpdate(timer, false); await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); @@ -1050,18 +1048,18 @@ namespace Jellyfin.LiveTv.EmbyTV timer.PrePaddingSeconds = 0; timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); timer.RetryCount++; - _timerProvider.AddOrUpdate(timer); + _timerManager.AddOrUpdate(timer); } else if (File.Exists(recordPath)) { timer.RecordingPath = recordPath; timer.Status = RecordingStatus.Completed; - _timerProvider.AddOrUpdate(timer, false); + _timerManager.AddOrUpdate(timer, false); OnSuccessfulRecording(timer, recordPath); } else { - _timerProvider.Delete(timer); + _timerManager.Delete(timer); } } @@ -1176,7 +1174,7 @@ namespace Jellyfin.LiveTv.EmbyTV } var seriesTimerId = timer.SeriesTimerId; - var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); + var seriesTimer = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) { @@ -1195,7 +1193,7 @@ namespace Jellyfin.LiveTv.EmbyTV return; } - var timersToDelete = _timerProvider.GetAll() + 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) @@ -1282,7 +1280,7 @@ namespace Jellyfin.LiveTv.EmbyTV _fileSystem.DeleteFile(timer.RecordingPath); } - _timerProvider.Delete(timer); + _timerManager.Delete(timer); } private string EnsureFileUnique(string path, string timerId) @@ -1896,7 +1894,7 @@ namespace Jellyfin.LiveTv.EmbyTV foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1)) { timer.Status = RecordingStatus.Cancelled; - _timerProvider.Update(timer); + _timerManager.Update(timer); } } @@ -1935,10 +1933,10 @@ namespace Jellyfin.LiveTv.EmbyTV var enabledTimersForSeries = new List(); foreach (var timer in allTimers) { - var existingTimer = _timerProvider.GetTimer(timer.Id) + var existingTimer = _timerManager.GetTimer(timer.Id) ?? (string.IsNullOrWhiteSpace(timer.ProgramId) ? null - : _timerProvider.GetTimerByProgramId(timer.ProgramId)); + : _timerManager.GetTimerByProgramId(timer.ProgramId)); if (existingTimer is null) { @@ -1951,7 +1949,7 @@ namespace Jellyfin.LiveTv.EmbyTV enabledTimersForSeries.Add(timer); } - _timerProvider.Add(timer); + _timerManager.Add(timer); TimerCreated?.Invoke(this, new GenericEventArgs(timer)); } @@ -1991,7 +1989,7 @@ namespace Jellyfin.LiveTv.EmbyTV } existingTimer.SeriesTimerId = seriesTimer.Id; - _timerProvider.Update(existingTimer); + _timerManager.Update(existingTimer); } } @@ -2008,7 +2006,7 @@ namespace Jellyfin.LiveTv.EmbyTV RecordingStatus.New }; - var deletes = _timerProvider.GetAll() + var deletes = _timerManager.GetAll() .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) .Where(i => deleteStatuses.Contains(i.Status)) diff --git a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs index 2ebe60b29..8a3fa7f36 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs @@ -1,6 +1,8 @@ #pragma warning disable CS1591 using System; +using System.IO; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.LiveTv; using Microsoft.Extensions.Logging; @@ -8,8 +10,11 @@ namespace Jellyfin.LiveTv.EmbyTV { public class SeriesTimerManager : ItemDataProvider { - public SeriesTimerManager(ILogger logger, string dataPath) - : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + public SeriesTimerManager(ILogger logger, IConfigurationManager config) + : base( + logger, + Path.Combine(config.CommonApplicationPaths.DataPath, "livetv/seriestimers.json"), + (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) { } diff --git a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs index 37b1fa14c..59ffa5d80 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Concurrent; using System.Globalization; +using System.IO; using System.Linq; using System.Threading; using Jellyfin.Data.Events; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.LiveTv; using Microsoft.Extensions.Logging; @@ -14,10 +16,13 @@ namespace Jellyfin.LiveTv.EmbyTV { public class TimerManager : ItemDataProvider { - private readonly ConcurrentDictionary _timers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _timers = new(StringComparer.OrdinalIgnoreCase); - public TimerManager(ILogger logger, string dataPath) - : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + public TimerManager(ILogger logger, IConfigurationManager config) + : base( + logger, + Path.Combine(config.CommonApplicationPaths.DataPath, "livetv"), + (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) { } @@ -80,22 +85,11 @@ namespace Jellyfin.LiveTv.EmbyTV AddOrUpdateSystemTimer(item); } - private static bool ShouldStartTimer(TimerInfo item) - { - if (item.Status == RecordingStatus.Completed - || item.Status == RecordingStatus.Cancelled) - { - return false; - } - - return true; - } - private void AddOrUpdateSystemTimer(TimerInfo item) { StopTimer(item); - if (!ShouldStartTimer(item)) + if (item.Status is RecordingStatus.Completed or RecordingStatus.Cancelled) { return; } @@ -169,13 +163,9 @@ namespace Jellyfin.LiveTv.EmbyTV } public TimerInfo? GetTimer(string id) - { - return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); - } + => GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); public TimerInfo? GetTimerByProgramId(string programId) - { - return GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase)); - } + => GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index e4800a031..b7ea5f54b 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Jellyfin.LiveTv.Channels; +using Jellyfin.LiveTv.EmbyTV; using Jellyfin.LiveTv.Guide; using Jellyfin.LiveTv.Listings; using Jellyfin.LiveTv.TunerHosts; @@ -22,6 +23,8 @@ public static class LiveTvServiceCollectionExtensions public static void AddLiveTvServices(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); -- cgit v1.2.3 From e13ccfe8547f7f3fbe01dc9ae378bf693f27c4bc Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 12:07:11 -0500 Subject: Move timer services to separate folder --- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 1 + src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs | 163 ------------------- src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs | 29 ---- src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs | 171 -------------------- .../LiveTvServiceCollectionExtensions.cs | 2 +- src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs | 163 +++++++++++++++++++ src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs | 29 ++++ src/Jellyfin.LiveTv/Timers/TimerManager.cs | 172 +++++++++++++++++++++ 8 files changed, 366 insertions(+), 364 deletions(-) delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs create mode 100644 src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs create mode 100644 src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs create mode 100644 src/Jellyfin.LiveTv/Timers/TimerManager.cs (limited to 'src') diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index a850ad6eb..48f5cea84 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -19,6 +19,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Timers; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; diff --git a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs b/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs deleted file mode 100644 index 547ffeb66..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs +++ /dev/null @@ -1,163 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Text.Json; -using Jellyfin.Extensions.Json; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public class ItemDataProvider - where T : class - { - private readonly string _dataPath; - private readonly object _fileDataLock = new object(); - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private T[]? _items; - - public ItemDataProvider( - ILogger logger, - string dataPath, - Func equalityComparer) - { - Logger = logger; - _dataPath = dataPath; - EqualityComparer = equalityComparer; - } - - protected ILogger Logger { get; } - - protected Func EqualityComparer { get; } - - [MemberNotNull(nameof(_items))] - private void EnsureLoaded() - { - if (_items is not null) - { - return; - } - - if (File.Exists(_dataPath)) - { - Logger.LogInformation("Loading live tv data from {Path}", _dataPath); - - try - { - var bytes = File.ReadAllBytes(_dataPath); - _items = JsonSerializer.Deserialize(bytes, _jsonOptions); - if (_items is null) - { - Logger.LogError("Error deserializing {Path}, data was null", _dataPath); - _items = Array.Empty(); - } - - return; - } - catch (JsonException ex) - { - Logger.LogError(ex, "Error deserializing {Path}", _dataPath); - } - } - - _items = Array.Empty(); - } - - private void SaveList() - { - Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath))); - var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); - File.WriteAllText(_dataPath, jsonString); - } - - public IReadOnlyList GetAll() - { - lock (_fileDataLock) - { - EnsureLoaded(); - return (T[])_items.Clone(); - } - } - - public virtual void Update(T item) - { - ArgumentNullException.ThrowIfNull(item); - - lock (_fileDataLock) - { - EnsureLoaded(); - - var index = Array.FindIndex(_items, i => EqualityComparer(i, item)); - if (index == -1) - { - throw new ArgumentException("item not found"); - } - - _items[index] = item; - - SaveList(); - } - } - - public virtual void Add(T item) - { - ArgumentNullException.ThrowIfNull(item); - - lock (_fileDataLock) - { - EnsureLoaded(); - - if (_items.Any(i => EqualityComparer(i, item))) - { - throw new ArgumentException("item already exists", nameof(item)); - } - - int oldLen = _items.Length; - var newList = new T[oldLen + 1]; - _items.CopyTo(newList, 0); - newList[oldLen] = item; - _items = newList; - - SaveList(); - } - } - - public virtual void AddOrUpdate(T item) - { - lock (_fileDataLock) - { - EnsureLoaded(); - - int index = Array.FindIndex(_items, i => EqualityComparer(i, item)); - if (index == -1) - { - int oldLen = _items.Length; - var newList = new T[oldLen + 1]; - _items.CopyTo(newList, 0); - newList[oldLen] = item; - _items = newList; - } - else - { - _items[index] = item; - } - - SaveList(); - } - } - - public virtual void Delete(T item) - { - lock (_fileDataLock) - { - EnsureLoaded(); - _items = _items.Where(i => !EqualityComparer(i, item)).ToArray(); - - SaveList(); - } - } - } -} diff --git a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs deleted file mode 100644 index 8a3fa7f36..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs +++ /dev/null @@ -1,29 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public class SeriesTimerManager : ItemDataProvider - { - public SeriesTimerManager(ILogger logger, IConfigurationManager config) - : base( - logger, - Path.Combine(config.CommonApplicationPaths.DataPath, "livetv/seriestimers.json"), - (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) - { - } - - /// - public override void Add(SeriesTimerInfo item) - { - ArgumentException.ThrowIfNullOrEmpty(item.Id); - - base.Add(item); - } - } -} diff --git a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs deleted file mode 100644 index 59ffa5d80..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs +++ /dev/null @@ -1,171 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading; -using Jellyfin.Data.Events; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public class TimerManager : ItemDataProvider - { - private readonly ConcurrentDictionary _timers = new(StringComparer.OrdinalIgnoreCase); - - public TimerManager(ILogger logger, IConfigurationManager config) - : base( - logger, - Path.Combine(config.CommonApplicationPaths.DataPath, "livetv"), - (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) - { - } - - public event EventHandler>? TimerFired; - - public void RestartTimers() - { - StopTimers(); - - foreach (var item in GetAll()) - { - AddOrUpdateSystemTimer(item); - } - } - - public void StopTimers() - { - foreach (var pair in _timers.ToList()) - { - pair.Value.Dispose(); - } - - _timers.Clear(); - } - - public override void Delete(TimerInfo item) - { - base.Delete(item); - StopTimer(item); - } - - public override void Update(TimerInfo item) - { - base.Update(item); - AddOrUpdateSystemTimer(item); - } - - public void AddOrUpdate(TimerInfo item, bool resetTimer) - { - if (resetTimer) - { - AddOrUpdate(item); - return; - } - - base.AddOrUpdate(item); - } - - public override void AddOrUpdate(TimerInfo item) - { - base.AddOrUpdate(item); - AddOrUpdateSystemTimer(item); - } - - public override void Add(TimerInfo item) - { - ArgumentException.ThrowIfNullOrEmpty(item.Id); - - base.Add(item); - AddOrUpdateSystemTimer(item); - } - - private void AddOrUpdateSystemTimer(TimerInfo item) - { - StopTimer(item); - - if (item.Status is RecordingStatus.Completed or RecordingStatus.Cancelled) - { - return; - } - - var startDate = RecordingHelper.GetStartTime(item); - var now = DateTime.UtcNow; - - if (startDate < now) - { - TimerFired?.Invoke(this, new GenericEventArgs(item)); - return; - } - - var dueTime = startDate - now; - StartTimer(item, dueTime); - } - - private void StartTimer(TimerInfo item, TimeSpan dueTime) - { - var timer = new Timer(TimerCallback, item.Id, dueTime, TimeSpan.Zero); - - if (_timers.TryAdd(item.Id, timer)) - { - if (item.IsSeries) - { - Logger.LogInformation( - "Creating recording timer for {Id}, {Name} {SeasonNumber}x{EpisodeNumber:D2} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", - item.Id, - item.Name, - item.SeasonNumber, - item.EpisodeNumber, - item.ChannelId, - dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), - item.StartDate); - } - else - { - Logger.LogInformation( - "Creating recording timer for {Id}, {Name} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", - item.Id, - item.Name, - item.ChannelId, - dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), - item.StartDate); - } - } - else - { - timer.Dispose(); - Logger.LogWarning("Timer already exists for item {Id}", item.Id); - } - } - - private void StopTimer(TimerInfo item) - { - if (_timers.TryRemove(item.Id, out var timer)) - { - timer.Dispose(); - } - } - - private void TimerCallback(object? state) - { - var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state)); - - var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); - if (timer is not null) - { - TimerFired?.Invoke(this, new GenericEventArgs(timer)); - } - } - - public TimerInfo? GetTimer(string id) - => GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); - - public TimerInfo? GetTimerByProgramId(string programId) - => GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase)); - } -} diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index b7ea5f54b..a632827f1 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ using Jellyfin.LiveTv.Channels; -using Jellyfin.LiveTv.EmbyTV; using Jellyfin.LiveTv.Guide; using Jellyfin.LiveTv.Listings; +using Jellyfin.LiveTv.Timers; using Jellyfin.LiveTv.TunerHosts; using Jellyfin.LiveTv.TunerHosts.HdHomerun; using MediaBrowser.Controller.Channels; diff --git a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs new file mode 100644 index 000000000..18e4810a2 --- /dev/null +++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs @@ -0,0 +1,163 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using Jellyfin.Extensions.Json; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Timers +{ + public class ItemDataProvider + where T : class + { + private readonly string _dataPath; + private readonly object _fileDataLock = new object(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private T[]? _items; + + public ItemDataProvider( + ILogger logger, + string dataPath, + Func equalityComparer) + { + Logger = logger; + _dataPath = dataPath; + EqualityComparer = equalityComparer; + } + + protected ILogger Logger { get; } + + protected Func EqualityComparer { get; } + + [MemberNotNull(nameof(_items))] + private void EnsureLoaded() + { + if (_items is not null) + { + return; + } + + if (File.Exists(_dataPath)) + { + Logger.LogInformation("Loading live tv data from {Path}", _dataPath); + + try + { + var bytes = File.ReadAllBytes(_dataPath); + _items = JsonSerializer.Deserialize(bytes, _jsonOptions); + if (_items is null) + { + Logger.LogError("Error deserializing {Path}, data was null", _dataPath); + _items = Array.Empty(); + } + + return; + } + catch (JsonException ex) + { + Logger.LogError(ex, "Error deserializing {Path}", _dataPath); + } + } + + _items = Array.Empty(); + } + + private void SaveList() + { + Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath))); + var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); + File.WriteAllText(_dataPath, jsonString); + } + + public IReadOnlyList GetAll() + { + lock (_fileDataLock) + { + EnsureLoaded(); + return (T[])_items.Clone(); + } + } + + public virtual void Update(T item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_fileDataLock) + { + EnsureLoaded(); + + var index = Array.FindIndex(_items, i => EqualityComparer(i, item)); + if (index == -1) + { + throw new ArgumentException("item not found"); + } + + _items[index] = item; + + SaveList(); + } + } + + public virtual void Add(T item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_fileDataLock) + { + EnsureLoaded(); + + if (_items.Any(i => EqualityComparer(i, item))) + { + throw new ArgumentException("item already exists", nameof(item)); + } + + int oldLen = _items.Length; + var newList = new T[oldLen + 1]; + _items.CopyTo(newList, 0); + newList[oldLen] = item; + _items = newList; + + SaveList(); + } + } + + public virtual void AddOrUpdate(T item) + { + lock (_fileDataLock) + { + EnsureLoaded(); + + int index = Array.FindIndex(_items, i => EqualityComparer(i, item)); + if (index == -1) + { + int oldLen = _items.Length; + var newList = new T[oldLen + 1]; + _items.CopyTo(newList, 0); + newList[oldLen] = item; + _items = newList; + } + else + { + _items[index] = item; + } + + SaveList(); + } + } + + public virtual void Delete(T item) + { + lock (_fileDataLock) + { + EnsureLoaded(); + _items = _items.Where(i => !EqualityComparer(i, item)).ToArray(); + + SaveList(); + } + } + } +} diff --git a/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs new file mode 100644 index 000000000..6e8444ba2 --- /dev/null +++ b/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs @@ -0,0 +1,29 @@ +#pragma warning disable CS1591 + +using System; +using System.IO; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Timers +{ + public class SeriesTimerManager : ItemDataProvider + { + public SeriesTimerManager(ILogger logger, IConfigurationManager config) + : base( + logger, + Path.Combine(config.CommonApplicationPaths.DataPath, "livetv/seriestimers.json"), + (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + } + + /// + public override void Add(SeriesTimerInfo item) + { + ArgumentException.ThrowIfNullOrEmpty(item.Id); + + base.Add(item); + } + } +} diff --git a/src/Jellyfin.LiveTv/Timers/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs new file mode 100644 index 000000000..6bcbd3324 --- /dev/null +++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs @@ -0,0 +1,172 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using Jellyfin.Data.Events; +using Jellyfin.LiveTv.EmbyTV; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Timers +{ + public class TimerManager : ItemDataProvider + { + private readonly ConcurrentDictionary _timers = new(StringComparer.OrdinalIgnoreCase); + + public TimerManager(ILogger logger, IConfigurationManager config) + : base( + logger, + Path.Combine(config.CommonApplicationPaths.DataPath, "livetv"), + (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + } + + public event EventHandler>? TimerFired; + + public void RestartTimers() + { + StopTimers(); + + foreach (var item in GetAll()) + { + AddOrUpdateSystemTimer(item); + } + } + + public void StopTimers() + { + foreach (var pair in _timers.ToList()) + { + pair.Value.Dispose(); + } + + _timers.Clear(); + } + + public override void Delete(TimerInfo item) + { + base.Delete(item); + StopTimer(item); + } + + public override void Update(TimerInfo item) + { + base.Update(item); + AddOrUpdateSystemTimer(item); + } + + public void AddOrUpdate(TimerInfo item, bool resetTimer) + { + if (resetTimer) + { + AddOrUpdate(item); + return; + } + + base.AddOrUpdate(item); + } + + public override void AddOrUpdate(TimerInfo item) + { + base.AddOrUpdate(item); + AddOrUpdateSystemTimer(item); + } + + public override void Add(TimerInfo item) + { + ArgumentException.ThrowIfNullOrEmpty(item.Id); + + base.Add(item); + AddOrUpdateSystemTimer(item); + } + + private void AddOrUpdateSystemTimer(TimerInfo item) + { + StopTimer(item); + + if (item.Status is RecordingStatus.Completed or RecordingStatus.Cancelled) + { + return; + } + + var startDate = RecordingHelper.GetStartTime(item); + var now = DateTime.UtcNow; + + if (startDate < now) + { + TimerFired?.Invoke(this, new GenericEventArgs(item)); + return; + } + + var dueTime = startDate - now; + StartTimer(item, dueTime); + } + + private void StartTimer(TimerInfo item, TimeSpan dueTime) + { + var timer = new Timer(TimerCallback, item.Id, dueTime, TimeSpan.Zero); + + if (_timers.TryAdd(item.Id, timer)) + { + if (item.IsSeries) + { + Logger.LogInformation( + "Creating recording timer for {Id}, {Name} {SeasonNumber}x{EpisodeNumber:D2} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", + item.Id, + item.Name, + item.SeasonNumber, + item.EpisodeNumber, + item.ChannelId, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), + item.StartDate); + } + else + { + Logger.LogInformation( + "Creating recording timer for {Id}, {Name} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", + item.Id, + item.Name, + item.ChannelId, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), + item.StartDate); + } + } + else + { + timer.Dispose(); + Logger.LogWarning("Timer already exists for item {Id}", item.Id); + } + } + + private void StopTimer(TimerInfo item) + { + if (_timers.TryRemove(item.Id, out var timer)) + { + timer.Dispose(); + } + } + + private void TimerCallback(object? state) + { + var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state)); + + var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (timer is not null) + { + TimerFired?.Invoke(this, new GenericEventArgs(timer)); + } + } + + public TimerInfo? GetTimer(string id) + => GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); + + public TimerInfo? GetTimerByProgramId(string programId) + => GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase)); + } +} -- cgit v1.2.3 From ca1a8ced48747dc3ec90f8d3d350246ad119d45a Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 9 Feb 2024 09:56:04 -0500 Subject: Move IO code to separate folder --- src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs | 118 ------- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 1 + src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs | 362 --------------------- src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs | 27 -- src/Jellyfin.LiveTv/ExclusiveLiveStream.cs | 61 ---- .../LiveTvServiceCollectionExtensions.cs | 1 + src/Jellyfin.LiveTv/IO/DirectRecorder.cs | 118 +++++++ src/Jellyfin.LiveTv/IO/EncodedRecorder.cs | 362 +++++++++++++++++++++ src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs | 61 ++++ src/Jellyfin.LiveTv/IO/IRecorder.cs | 27 ++ src/Jellyfin.LiveTv/IO/StreamHelper.cs | 120 +++++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 1 + src/Jellyfin.LiveTv/StreamHelper.cs | 120 ------- 13 files changed, 691 insertions(+), 688 deletions(-) delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs delete mode 100644 src/Jellyfin.LiveTv/ExclusiveLiveStream.cs create mode 100644 src/Jellyfin.LiveTv/IO/DirectRecorder.cs create mode 100644 src/Jellyfin.LiveTv/IO/EncodedRecorder.cs create mode 100644 src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs create mode 100644 src/Jellyfin.LiveTv/IO/IRecorder.cs create mode 100644 src/Jellyfin.LiveTv/IO/StreamHelper.cs delete mode 100644 src/Jellyfin.LiveTv/StreamHelper.cs (limited to 'src') diff --git a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs deleted file mode 100644 index 2a25218b6..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs +++ /dev/null @@ -1,118 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Streaming; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public sealed class DirectRecorder : IRecorder - { - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IStreamHelper _streamHelper; - - public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - _streamHelper = streamHelper; - } - - public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) - { - return targetFile; - } - - public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - if (directStreamProvider is not null) - { - return RecordFromDirectStreamProvider(directStreamProvider, targetFile, duration, onStarted, cancellationToken); - } - - return RecordFromMediaSource(mediaSource, targetFile, duration, onStarted, cancellationToken); - } - - private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - - var output = new FileStream( - targetFile, - FileMode.CreateNew, - FileAccess.Write, - FileShare.Read, - IODefaults.FileStreamBufferSize, - FileOptions.Asynchronous); - - await using (output.ConfigureAwait(false)) - { - onStarted(); - - _logger.LogInformation("Copying recording to file {FilePath}", targetFile); - - // The media source is infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - var linkedCancellationToken = cancellationTokenSource.Token; - var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream()); - await using (fileStream.ConfigureAwait(false)) - { - await _streamHelper.CopyToAsync( - fileStream, - output, - IODefaults.CopyToBufferSize, - 1000, - linkedCancellationToken).ConfigureAwait(false); - } - } - - _logger.LogInformation("Recording completed: {FilePath}", targetFile); - } - - private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - - _logger.LogInformation("Opened recording stream from tuner provider"); - - Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - - var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); - await using (output.ConfigureAwait(false)) - { - onStarted(); - - _logger.LogInformation("Copying recording stream to file {0}", targetFile); - - // The media source if infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - cancellationToken = linkedCancellationToken.Token; - - await _streamHelper.CopyUntilCancelled( - await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), - output, - IODefaults.CopyToBufferSize, - cancellationToken).ConfigureAwait(false); - - _logger.LogInformation("Recording completed to file {0}", targetFile); - } - } - - /// - public void Dispose() - { - } - } -} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 48f5cea84..cfd142d43 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -19,6 +19,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.IO; using Jellyfin.LiveTv.Timers; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; diff --git a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs deleted file mode 100644 index 132a5fc51..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs +++ /dev/null @@ -1,362 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Extensions; -using Jellyfin.Extensions.Json; -using MediaBrowser.Common; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public class EncodedRecorder : IRecorder - { - private readonly ILogger _logger; - private readonly IMediaEncoder _mediaEncoder; - private readonly IServerApplicationPaths _appPaths; - private readonly TaskCompletionSource _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private bool _hasExited; - private FileStream _logFileStream; - private string _targetPath; - private Process _process; - private bool _disposed; - - public EncodedRecorder( - ILogger logger, - IMediaEncoder mediaEncoder, - IServerApplicationPaths appPaths, - IServerConfigurationManager serverConfigurationManager) - { - _logger = logger; - _mediaEncoder = mediaEncoder; - _appPaths = appPaths; - _serverConfigurationManager = serverConfigurationManager; - } - - private static bool CopySubtitles => false; - - public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) - { - return Path.ChangeExtension(targetFile, ".ts"); - } - - public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - // The media source is infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - - await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); - - _logger.LogInformation("Recording completed to file {Path}", targetFile); - } - - private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onStarted, CancellationToken cancellationToken) - { - _targetPath = targetFile; - Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); - - var processStartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - UseShellExecute = false, - - RedirectStandardError = true, - RedirectStandardInput = true, - - FileName = _mediaEncoder.EncoderPath, - Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile), - - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }; - - _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments); - - var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); - Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); - - // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - - await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); - await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processStartInfo.FileName + " " + processStartInfo.Arguments + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); - - _process = new Process - { - StartInfo = processStartInfo, - EnableRaisingEvents = true - }; - _process.Exited += (_, _) => OnFfMpegProcessExited(_process); - - _process.Start(); - - cancellationToken.Register(Stop); - - onStarted(); - - // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback - _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); - - _logger.LogInformation("ffmpeg recording process started for {Path}", _targetPath); - - // Block until ffmpeg exits - await _taskCompletionSource.Task.ConfigureAwait(false); - } - - private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) - { - string videoArgs; - if (EncodeVideo(mediaSource)) - { - const int MaxBitrate = 25000000; - videoArgs = string.Format( - CultureInfo.InvariantCulture, - "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", - GetOutputSizeParam(), - MaxBitrate); - } - else - { - videoArgs = "-codec:v:0 copy"; - } - - videoArgs += " -fflags +genpts"; - - var flags = new List(); - if (mediaSource.IgnoreDts) - { - flags.Add("+igndts"); - } - - if (mediaSource.IgnoreIndex) - { - flags.Add("+ignidx"); - } - - if (mediaSource.GenPtsInput) - { - flags.Add("+genpts"); - } - - var inputModifier = "-async 1 -vsync -1"; - - if (flags.Count > 0) - { - inputModifier += " -fflags " + string.Join(string.Empty, flags); - } - - if (mediaSource.ReadAtNativeFramerate) - { - inputModifier += " -re"; - } - - if (mediaSource.RequiresLooping) - { - inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2"; - } - - var analyzeDurationSeconds = 5; - var analyzeDuration = " -analyzeduration " + - (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture); - inputModifier += analyzeDuration; - - var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; - - // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ? - // " -f mp4 -movflags frag_keyframe+empty_moov" : - // string.Empty; - - var outputParam = string.Empty; - - var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); - var commandLineArgs = string.Format( - CultureInfo.InvariantCulture, - "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", - inputTempFile, - targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename - videoArgs, - GetAudioArgs(mediaSource), - subtitleArgs, - outputParam, - threads); - - return inputModifier + " " + commandLineArgs; - } - - private static string GetAudioArgs(MediaSourceInfo mediaSource) - { - return "-codec:a:0 copy"; - - // var audioChannels = 2; - // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); - // if (audioStream is not null) - // { - // audioChannels = audioStream.Channels ?? audioChannels; - // } - // return "-codec:a:0 aac -strict experimental -ab 320000"; - } - - private static bool EncodeVideo(MediaSourceInfo mediaSource) - { - return false; - } - - protected string GetOutputSizeParam() - => "-vf \"yadif=0:-1:0\""; - - private void Stop() - { - if (!_hasExited) - { - try - { - _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); - - _process.StandardInput.WriteLine("q"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); - } - - if (_hasExited) - { - return; - } - - try - { - _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); - - if (_process.WaitForExit(10000)) - { - return; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); - } - - if (_hasExited) - { - return; - } - - try - { - _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); - - _process.Kill(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); - } - } - } - - /// - /// Processes the exited. - /// - private void OnFfMpegProcessExited(Process process) - { - using (process) - { - _hasExited = true; - - _logFileStream?.Dispose(); - _logFileStream = null; - - var exitCode = process.ExitCode; - - _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath); - - if (exitCode == 0) - { - _taskCompletionSource.TrySetResult(true); - } - else - { - _taskCompletionSource.TrySetException( - new FfmpegException( - string.Format( - CultureInfo.InvariantCulture, - "Recording for {0} failed. Exit code {1}", - _targetPath, - exitCode))); - } - } - } - - private async Task StartStreamingLog(Stream source, FileStream target) - { - try - { - using (var reader = new StreamReader(source)) - { - await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) - { - var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); - - await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); - await target.FlushAsync().ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error reading ffmpeg recording log"); - } - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and optionally managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _logFileStream?.Dispose(); - _process?.Dispose(); - } - - _logFileStream = null; - _process = null; - - _disposed = true; - } - } -} diff --git a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs deleted file mode 100644 index 7ed42e263..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs +++ /dev/null @@ -1,27 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public interface IRecorder : IDisposable - { - /// - /// Records the specified media source. - /// - /// The direct stream provider, or null. - /// The media source. - /// The target file. - /// The duration to record. - /// An action to perform when recording starts. - /// The cancellation token. - /// A that represents the recording operation. - Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); - - string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); - } -} diff --git a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs b/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs deleted file mode 100644 index 9d442e20c..000000000 --- a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs +++ /dev/null @@ -1,61 +0,0 @@ -#nullable disable - -#pragma warning disable CA1711 -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; - -namespace Jellyfin.LiveTv -{ - public sealed class ExclusiveLiveStream : ILiveStream - { - private readonly Func _closeFn; - - public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func closeFn) - { - MediaSource = mediaSource; - EnableStreamSharing = false; - _closeFn = closeFn; - ConsumerCount = 1; - UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - } - - public int ConsumerCount { get; set; } - - public string OriginalStreamId { get; set; } - - public string TunerHostId => null; - - public bool EnableStreamSharing { get; set; } - - public MediaSourceInfo MediaSource { get; set; } - - public string UniqueId { get; } - - public Task Close() - { - return _closeFn(); - } - - public Stream GetStream() - { - throw new NotSupportedException(); - } - - public Task Open(CancellationToken openCancellationToken) - { - return Task.CompletedTask; - } - - /// - public void Dispose() - { - } - } -} diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index a632827f1..4f05a85e4 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.IO; using Jellyfin.LiveTv.Listings; using Jellyfin.LiveTv.Timers; using Jellyfin.LiveTv.TunerHosts; diff --git a/src/Jellyfin.LiveTv/IO/DirectRecorder.cs b/src/Jellyfin.LiveTv/IO/DirectRecorder.cs new file mode 100644 index 000000000..c4ec6de40 --- /dev/null +++ b/src/Jellyfin.LiveTv/IO/DirectRecorder.cs @@ -0,0 +1,118 @@ +#pragma warning disable CS1591 + +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Streaming; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.IO +{ + public sealed class DirectRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IStreamHelper _streamHelper; + + public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _streamHelper = streamHelper; + } + + public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) + { + return targetFile; + } + + public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + if (directStreamProvider is not null) + { + return RecordFromDirectStreamProvider(directStreamProvider, targetFile, duration, onStarted, cancellationToken); + } + + return RecordFromMediaSource(mediaSource, targetFile, duration, onStarted, cancellationToken); + } + + private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); + + var output = new FileStream( + targetFile, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + await using (output.ConfigureAwait(false)) + { + onStarted(); + + _logger.LogInformation("Copying recording to file {FilePath}", targetFile); + + // The media source is infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + var linkedCancellationToken = cancellationTokenSource.Token; + var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream()); + await using (fileStream.ConfigureAwait(false)) + { + await _streamHelper.CopyToAsync( + fileStream, + output, + IODefaults.CopyToBufferSize, + 1000, + linkedCancellationToken).ConfigureAwait(false); + } + } + + _logger.LogInformation("Recording completed: {FilePath}", targetFile); + } + + private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Opened recording stream from tuner provider"); + + Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); + + var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); + await using (output.ConfigureAwait(false)) + { + onStarted(); + + _logger.LogInformation("Copying recording stream to file {0}", targetFile); + + // The media source if infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + cancellationToken = linkedCancellationToken.Token; + + await _streamHelper.CopyUntilCancelled( + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + output, + IODefaults.CopyToBufferSize, + cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Recording completed to file {0}", targetFile); + } + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs new file mode 100644 index 000000000..ff00c8999 --- /dev/null +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -0,0 +1,362 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.IO +{ + public class EncodedRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IMediaEncoder _mediaEncoder; + private readonly IServerApplicationPaths _appPaths; + private readonly TaskCompletionSource _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private bool _hasExited; + private FileStream _logFileStream; + private string _targetPath; + private Process _process; + private bool _disposed; + + public EncodedRecorder( + ILogger logger, + IMediaEncoder mediaEncoder, + IServerApplicationPaths appPaths, + IServerConfigurationManager serverConfigurationManager) + { + _logger = logger; + _mediaEncoder = mediaEncoder; + _appPaths = appPaths; + _serverConfigurationManager = serverConfigurationManager; + } + + private static bool CopySubtitles => false; + + public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) + { + return Path.ChangeExtension(targetFile, ".ts"); + } + + public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + // The media source is infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + + await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); + + _logger.LogInformation("Recording completed to file {Path}", targetFile); + } + + private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onStarted, CancellationToken cancellationToken) + { + _targetPath = targetFile; + Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + + var processStartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + + RedirectStandardError = true, + RedirectStandardInput = true, + + FileName = _mediaEncoder.EncoderPath, + Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile), + + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }; + + _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments); + + var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); + Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); + + // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + + await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); + await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processStartInfo.FileName + " " + processStartInfo.Arguments + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); + + _process = new Process + { + StartInfo = processStartInfo, + EnableRaisingEvents = true + }; + _process.Exited += (_, _) => OnFfMpegProcessExited(_process); + + _process.Start(); + + cancellationToken.Register(Stop); + + onStarted(); + + // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); + + _logger.LogInformation("ffmpeg recording process started for {Path}", _targetPath); + + // Block until ffmpeg exits + await _taskCompletionSource.Task.ConfigureAwait(false); + } + + private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) + { + string videoArgs; + if (EncodeVideo(mediaSource)) + { + const int MaxBitrate = 25000000; + videoArgs = string.Format( + CultureInfo.InvariantCulture, + "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", + GetOutputSizeParam(), + MaxBitrate); + } + else + { + videoArgs = "-codec:v:0 copy"; + } + + videoArgs += " -fflags +genpts"; + + var flags = new List(); + if (mediaSource.IgnoreDts) + { + flags.Add("+igndts"); + } + + if (mediaSource.IgnoreIndex) + { + flags.Add("+ignidx"); + } + + if (mediaSource.GenPtsInput) + { + flags.Add("+genpts"); + } + + var inputModifier = "-async 1 -vsync -1"; + + if (flags.Count > 0) + { + inputModifier += " -fflags " + string.Join(string.Empty, flags); + } + + if (mediaSource.ReadAtNativeFramerate) + { + inputModifier += " -re"; + } + + if (mediaSource.RequiresLooping) + { + inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2"; + } + + var analyzeDurationSeconds = 5; + var analyzeDuration = " -analyzeduration " + + (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture); + inputModifier += analyzeDuration; + + var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; + + // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ? + // " -f mp4 -movflags frag_keyframe+empty_moov" : + // string.Empty; + + var outputParam = string.Empty; + + var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); + var commandLineArgs = string.Format( + CultureInfo.InvariantCulture, + "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", + inputTempFile, + targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename + videoArgs, + GetAudioArgs(mediaSource), + subtitleArgs, + outputParam, + threads); + + return inputModifier + " " + commandLineArgs; + } + + private static string GetAudioArgs(MediaSourceInfo mediaSource) + { + return "-codec:a:0 copy"; + + // var audioChannels = 2; + // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); + // if (audioStream is not null) + // { + // audioChannels = audioStream.Channels ?? audioChannels; + // } + // return "-codec:a:0 aac -strict experimental -ab 320000"; + } + + private static bool EncodeVideo(MediaSourceInfo mediaSource) + { + return false; + } + + protected string GetOutputSizeParam() + => "-vf \"yadif=0:-1:0\""; + + private void Stop() + { + if (!_hasExited) + { + try + { + _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); + + _process.StandardInput.WriteLine("q"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); + } + + if (_hasExited) + { + return; + } + + try + { + _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); + + if (_process.WaitForExit(10000)) + { + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); + } + + if (_hasExited) + { + return; + } + + try + { + _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); + + _process.Kill(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); + } + } + } + + /// + /// Processes the exited. + /// + private void OnFfMpegProcessExited(Process process) + { + using (process) + { + _hasExited = true; + + _logFileStream?.Dispose(); + _logFileStream = null; + + var exitCode = process.ExitCode; + + _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath); + + if (exitCode == 0) + { + _taskCompletionSource.TrySetResult(true); + } + else + { + _taskCompletionSource.TrySetException( + new FfmpegException( + string.Format( + CultureInfo.InvariantCulture, + "Recording for {0} failed. Exit code {1}", + _targetPath, + exitCode))); + } + } + } + + private async Task StartStreamingLog(Stream source, FileStream target) + { + try + { + using (var reader = new StreamReader(source)) + { + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) + { + var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); + + await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); + await target.FlushAsync().ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading ffmpeg recording log"); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and optionally managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _logFileStream?.Dispose(); + _process?.Dispose(); + } + + _logFileStream = null; + _process = null; + + _disposed = true; + } + } +} diff --git a/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs b/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs new file mode 100644 index 000000000..394b9cf11 --- /dev/null +++ b/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs @@ -0,0 +1,61 @@ +#nullable disable + +#pragma warning disable CA1711 +#pragma warning disable CS1591 + +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.LiveTv.IO +{ + public sealed class ExclusiveLiveStream : ILiveStream + { + private readonly Func _closeFn; + + public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func closeFn) + { + MediaSource = mediaSource; + EnableStreamSharing = false; + _closeFn = closeFn; + ConsumerCount = 1; + UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + } + + public int ConsumerCount { get; set; } + + public string OriginalStreamId { get; set; } + + public string TunerHostId => null; + + public bool EnableStreamSharing { get; set; } + + public MediaSourceInfo MediaSource { get; set; } + + public string UniqueId { get; } + + public Task Close() + { + return _closeFn(); + } + + public Stream GetStream() + { + throw new NotSupportedException(); + } + + public Task Open(CancellationToken openCancellationToken) + { + return Task.CompletedTask; + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Jellyfin.LiveTv/IO/IRecorder.cs b/src/Jellyfin.LiveTv/IO/IRecorder.cs new file mode 100644 index 000000000..ab4506414 --- /dev/null +++ b/src/Jellyfin.LiveTv/IO/IRecorder.cs @@ -0,0 +1,27 @@ +#pragma warning disable CS1591 + +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.LiveTv.IO +{ + public interface IRecorder : IDisposable + { + /// + /// Records the specified media source. + /// + /// The direct stream provider, or null. + /// The media source. + /// The target file. + /// The duration to record. + /// An action to perform when recording starts. + /// The cancellation token. + /// A that represents the recording operation. + Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); + + string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); + } +} diff --git a/src/Jellyfin.LiveTv/IO/StreamHelper.cs b/src/Jellyfin.LiveTv/IO/StreamHelper.cs new file mode 100644 index 000000000..7947807ba --- /dev/null +++ b/src/Jellyfin.LiveTv/IO/StreamHelper.cs @@ -0,0 +1,120 @@ +#pragma warning disable CS1591 + +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; + +namespace Jellyfin.LiveTv.IO +{ + public class StreamHelper : IStreamHelper + { + public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action? onStarted, CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + int read; + while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + + if (onStarted is not null) + { + onStarted(); + onStarted = null; + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, int emptyReadLimit, CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + if (emptyReadLimit <= 0) + { + int read; + while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + } + + return; + } + + var eofCount = 0; + + while (eofCount < emptyReadLimit) + { + cancellationToken.ThrowIfCancellationRequested(); + + var bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + + if (bytesRead == 0) + { + eofCount++; + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + else + { + eofCount = 0; + + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public async Task CopyUntilCancelled(Stream source, Stream target, int bufferSize, CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + while (!cancellationToken.IsCancellationRequested) + { + var bytesRead = await CopyToAsyncInternal(source, target, buffer, cancellationToken).ConfigureAwait(false); + + if (bytesRead == 0) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static async Task CopyToAsyncInternal(Stream source, Stream destination, byte[] buffer, CancellationToken cancellationToken) + { + int bytesRead; + int totalBytesRead = 0; + + while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) + { + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); + + totalBytesRead += bytesRead; + } + + return totalBytesRead; + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 1b69fd7fd..6b4ce6f7c 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -12,6 +12,7 @@ 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; diff --git a/src/Jellyfin.LiveTv/StreamHelper.cs b/src/Jellyfin.LiveTv/StreamHelper.cs deleted file mode 100644 index e9644e95e..000000000 --- a/src/Jellyfin.LiveTv/StreamHelper.cs +++ /dev/null @@ -1,120 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Buffers; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.IO; - -namespace Jellyfin.LiveTv -{ - public class StreamHelper : IStreamHelper - { - public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action? onStarted, CancellationToken cancellationToken) - { - byte[] buffer = ArrayPool.Shared.Rent(bufferSize); - try - { - int read; - while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) - { - cancellationToken.ThrowIfCancellationRequested(); - - await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); - - if (onStarted is not null) - { - onStarted(); - onStarted = null; - } - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, int emptyReadLimit, CancellationToken cancellationToken) - { - byte[] buffer = ArrayPool.Shared.Rent(bufferSize); - try - { - if (emptyReadLimit <= 0) - { - int read; - while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) - { - cancellationToken.ThrowIfCancellationRequested(); - - await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); - } - - return; - } - - var eofCount = 0; - - while (eofCount < emptyReadLimit) - { - cancellationToken.ThrowIfCancellationRequested(); - - var bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - - if (bytesRead == 0) - { - eofCount++; - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } - else - { - eofCount = 0; - - await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); - } - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - public async Task CopyUntilCancelled(Stream source, Stream target, int bufferSize, CancellationToken cancellationToken) - { - byte[] buffer = ArrayPool.Shared.Rent(bufferSize); - try - { - while (!cancellationToken.IsCancellationRequested) - { - var bytesRead = await CopyToAsyncInternal(source, target, buffer, cancellationToken).ConfigureAwait(false); - - if (bytesRead == 0) - { - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private static async Task CopyToAsyncInternal(Stream source, Stream destination, byte[] buffer, CancellationToken cancellationToken) - { - int bytesRead; - int totalBytesRead = 0; - - while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) - { - await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); - - totalBytesRead += bytesRead; - } - - return totalBytesRead; - } - } -} -- cgit v1.2.3 From 7baf2d6c6bdaa51c3ecd0d628d36a0dacbd2bc54 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 12:28:27 -0500 Subject: Add RecordingsMetadataManager service --- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 458 +------------------ .../LiveTvServiceCollectionExtensions.cs | 2 + .../Recordings/RecordingsMetadataManager.cs | 502 +++++++++++++++++++++ 3 files changed, 510 insertions(+), 452 deletions(-) create mode 100644 src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs (limited to 'src') diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index cfd142d43..d1688dfd9 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -10,16 +10,15 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; -using System.Xml; using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; using Jellyfin.LiveTv.IO; +using Jellyfin.LiveTv.Recordings; using Jellyfin.LiveTv.Timers; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -44,8 +43,6 @@ namespace Jellyfin.LiveTv.EmbyTV { public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable { - public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; - private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; @@ -61,6 +58,7 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly LiveTvDtoService _tvDtoService; private readonly TimerManager _timerManager; private readonly ItemDataProvider _seriesTimerManager; + private readonly RecordingsMetadataManager _recordingsMetadataManager; private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); @@ -84,7 +82,8 @@ namespace Jellyfin.LiveTv.EmbyTV IListingsManager listingsManager, LiveTvDtoService tvDtoService, TimerManager timerManager, - SeriesTimerManager seriesTimerManager) + SeriesTimerManager seriesTimerManager, + RecordingsMetadataManager recordingsMetadataManager) { Current = this; @@ -103,6 +102,7 @@ namespace Jellyfin.LiveTv.EmbyTV _tvDtoService = tvDtoService; _timerManager = timerManager; _seriesTimerManager = seriesTimerManager; + _recordingsMetadataManager = recordingsMetadataManager; _timerManager.TimerFired += OnTimerManagerTimerFired; _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; @@ -998,7 +998,7 @@ namespace Jellyfin.LiveTv.EmbyTV timer.Status = RecordingStatus.InProgress; _timerManager.AddOrUpdate(timer, false); - await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); + await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); await CreateRecordingFolders().ConfigureAwait(false); @@ -1377,452 +1377,6 @@ namespace Jellyfin.LiveTv.EmbyTV } } - private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image) - { - if (!image.IsLocalFile) - { - image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false); - } - - string imageSaveFilenameWithoutExtension = image.Type switch - { - ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster", - ImageType.Logo => "logo", - ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape", - ImageType.Backdrop => "fanart", - _ => null - }; - - if (imageSaveFilenameWithoutExtension is null) - { - return; - } - - var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath), imageSaveFilenameWithoutExtension); - - // preserve original image extension - imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path)); - - File.Copy(image.Path, imageSavePath, true); - } - - private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program) - { - var image = program.IsSeries ? - (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) : - (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0)); - - if (image is not null) - { - try - { - await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving recording image"); - } - } - - if (!program.IsSeries) - { - image = program.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving recording image"); - } - } - - image = program.GetImageInfo(ImageType.Thumb, 0); - if (image is not null) - { - try - { - await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving recording image"); - } - } - - image = program.GetImageInfo(ImageType.Logo, 0); - if (image is not null) - { - try - { - await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving recording image"); - } - } - } - } - - private async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string seriesPath) - { - try - { - var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - Limit = 1, - ExternalId = timer.ProgramId, - DtoOptions = new DtoOptions(true) - }).FirstOrDefault() as LiveTvProgram; - - // dummy this up - if (program is null) - { - program = new LiveTvProgram - { - Name = timer.Name, - Overview = timer.Overview, - Genres = timer.Genres, - CommunityRating = timer.CommunityRating, - OfficialRating = timer.OfficialRating, - ProductionYear = timer.ProductionYear, - PremiereDate = timer.OriginalAirDate, - IndexNumber = timer.EpisodeNumber, - ParentIndexNumber = timer.SeasonNumber - }; - } - - if (timer.IsSports) - { - program.AddGenre("Sports"); - } - - if (timer.IsKids) - { - program.AddGenre("Kids"); - program.AddGenre("Children"); - } - - if (timer.IsNews) - { - program.AddGenre("News"); - } - - var config = _config.GetLiveTvConfiguration(); - - if (config.SaveRecordingNFO) - { - if (timer.IsProgramSeries) - { - await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false); - await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); - } - else if (!timer.IsMovie || timer.IsSports || timer.IsNews) - { - await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false); - } - else - { - await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); - } - } - - if (config.SaveRecordingImages) - { - await SaveRecordingImages(recordingPath, program).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving nfo"); - } - } - - private async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath) - { - var nfoPath = Path.Combine(seriesPath, "tvshow.nfo"); - - if (File.Exists(nfoPath)) - { - return; - } - - var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - await using (stream.ConfigureAwait(false)) - { - var settings = new XmlWriterSettings - { - Indent = true, - Encoding = Encoding.UTF8, - Async = true - }; - - var writer = XmlWriter.Create(stream, settings); - await using (writer.ConfigureAwait(false)) - { - await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); - await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false); - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id)) - { - await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false); - } - - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id)) - { - await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false); - } - - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id)) - { - await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false); - } - - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id)) - { - await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(timer.Name)) - { - await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(timer.OfficialRating)) - { - await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false); - } - - foreach (var genre in timer.Genres) - { - await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); - } - - await writer.WriteEndElementAsync().ConfigureAwait(false); - await writer.WriteEndDocumentAsync().ConfigureAwait(false); - } - } - } - - private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData) - { - var nfoPath = Path.ChangeExtension(recordingPath, ".nfo"); - - if (File.Exists(nfoPath)) - { - return; - } - - var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - await using (stream.ConfigureAwait(false)) - { - var settings = new XmlWriterSettings - { - Indent = true, - Encoding = Encoding.UTF8, - Async = true - }; - - var options = _config.GetNfoConfiguration(); - - var isSeriesEpisode = timer.IsProgramSeries; - - var writer = XmlWriter.Create(stream, settings); - await using (writer.ConfigureAwait(false)) - { - await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); - - if (isSeriesEpisode) - { - await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false); - - if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle)) - { - await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(false); - } - - var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null); - - if (premiereDate.HasValue) - { - var formatString = options.ReleaseDateFormat; - - await writer.WriteElementStringAsync( - null, - "aired", - null, - premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (item.IndexNumber.HasValue) - { - await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (item.ParentIndexNumber.HasValue) - { - await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - } - else - { - await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false); - - if (!string.IsNullOrWhiteSpace(item.Name)) - { - await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(item.OriginalTitle)) - { - await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false); - } - - if (item.PremiereDate.HasValue) - { - var formatString = options.ReleaseDateFormat; - - await writer.WriteElementStringAsync( - null, - "premiered", - null, - item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); - await writer.WriteElementStringAsync( - null, - "releasedate", - null, - item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - } - - await writer.WriteElementStringAsync( - null, - "dateadded", - null, - DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false); - - if (item.ProductionYear.HasValue) - { - await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (!string.IsNullOrEmpty(item.OfficialRating)) - { - await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false); - } - - var overview = (item.Overview ?? string.Empty) - .StripHtml() - .Replace(""", "'", StringComparison.Ordinal); - - await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false); - - if (item.CommunityRating.HasValue) - { - await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - foreach (var genre in item.Genres) - { - await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); - } - - var people = item.Id.IsEmpty() ? new List() : _libraryManager.GetPeople(item); - - var directors = people - .Where(i => i.IsType(PersonKind.Director)) - .Select(i => i.Name) - .ToList(); - - foreach (var person in directors) - { - await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false); - } - - var writers = people - .Where(i => i.IsType(PersonKind.Writer)) - .Select(i => i.Name) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var person in writers) - { - await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false); - } - - foreach (var person in writers) - { - await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false); - } - - var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); - - if (!string.IsNullOrEmpty(tmdbCollection)) - { - await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false); - } - - var imdb = item.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdb)) - { - if (!isSeriesEpisode) - { - await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false); - } - - await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - var tvdb = item.GetProviderId(MetadataProvider.Tvdb); - if (!string.IsNullOrEmpty(tvdb)) - { - await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - var tmdb = item.GetProviderId(MetadataProvider.Tmdb); - if (!string.IsNullOrEmpty(tmdb)) - { - await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - if (lockData) - { - await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false); - } - - if (item.CriticRating.HasValue) - { - await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(item.Tagline)) - { - await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false); - } - - foreach (var studio in item.Studios) - { - await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false); - } - - await writer.WriteEndElementAsync().ConfigureAwait(false); - await writer.WriteEndDocumentAsync().ConfigureAwait(false); - } - } - } - private LiveTvProgram GetProgramInfoFromCache(string programId) { var query = new InternalItemsQuery diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index 4f05a85e4..d02be31cf 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Jellyfin.LiveTv.Guide; using Jellyfin.LiveTv.IO; using Jellyfin.LiveTv.Listings; +using Jellyfin.LiveTv.Recordings; using Jellyfin.LiveTv.Timers; using Jellyfin.LiveTv.TunerHosts; using Jellyfin.LiveTv.TunerHosts.HdHomerun; @@ -26,6 +27,7 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs new file mode 100644 index 000000000..0a71a4d46 --- /dev/null +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.EmbyTV; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Recordings; + +/// +/// A service responsible for saving recording metadata. +/// +public class RecordingsMetadataManager +{ + private const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; + + private readonly ILogger _logger; + private readonly IConfigurationManager _config; + private readonly ILibraryManager _libraryManager; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + public RecordingsMetadataManager( + ILogger logger, + IConfigurationManager config, + ILibraryManager libraryManager) + { + _logger = logger; + _config = config; + _libraryManager = libraryManager; + } + + /// + /// Saves the metadata for a provided recording. + /// + /// The recording timer. + /// The recording path. + /// The series path. + /// A task representing the metadata saving. + public async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string? seriesPath) + { + try + { + var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.LiveTvProgram], + Limit = 1, + ExternalId = timer.ProgramId, + DtoOptions = new DtoOptions(true) + }).FirstOrDefault() as LiveTvProgram; + + // dummy this up + program ??= new LiveTvProgram + { + Name = timer.Name, + Overview = timer.Overview, + Genres = timer.Genres, + CommunityRating = timer.CommunityRating, + OfficialRating = timer.OfficialRating, + ProductionYear = timer.ProductionYear, + PremiereDate = timer.OriginalAirDate, + IndexNumber = timer.EpisodeNumber, + ParentIndexNumber = timer.SeasonNumber + }; + + if (timer.IsSports) + { + program.AddGenre("Sports"); + } + + if (timer.IsKids) + { + program.AddGenre("Kids"); + program.AddGenre("Children"); + } + + if (timer.IsNews) + { + program.AddGenre("News"); + } + + var config = _config.GetLiveTvConfiguration(); + + if (config.SaveRecordingNFO) + { + if (timer.IsProgramSeries) + { + ArgumentNullException.ThrowIfNull(seriesPath); + + await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false); + await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); + } + else if (!timer.IsMovie || timer.IsSports || timer.IsNews) + { + await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false); + } + else + { + await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); + } + } + + if (config.SaveRecordingImages) + { + await SaveRecordingImages(recordingPath, program).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving nfo"); + } + } + + private static async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath) + { + var nfoPath = Path.Combine(seriesPath, "tvshow.nfo"); + + if (File.Exists(nfoPath)) + { + return; + } + + var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await using (stream.ConfigureAwait(false)) + { + var settings = new XmlWriterSettings + { + Indent = true, + Encoding = Encoding.UTF8, + Async = true + }; + + var writer = XmlWriter.Create(stream, settings); + await using (writer.ConfigureAwait(false)) + { + await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); + await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false); + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id)) + { + await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false); + } + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id)) + { + await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false); + } + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id)) + { + await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false); + } + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id)) + { + await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(timer.Name)) + { + await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(timer.OfficialRating)) + { + await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false); + } + + foreach (var genre in timer.Genres) + { + await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); + await writer.WriteEndDocumentAsync().ConfigureAwait(false); + } + } + } + + private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData) + { + var nfoPath = Path.ChangeExtension(recordingPath, ".nfo"); + + if (File.Exists(nfoPath)) + { + return; + } + + var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await using (stream.ConfigureAwait(false)) + { + var settings = new XmlWriterSettings + { + Indent = true, + Encoding = Encoding.UTF8, + Async = true + }; + + var options = _config.GetNfoConfiguration(); + + var isSeriesEpisode = timer.IsProgramSeries; + + var writer = XmlWriter.Create(stream, settings); + await using (writer.ConfigureAwait(false)) + { + await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); + + if (isSeriesEpisode) + { + await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle)) + { + await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(false); + } + + var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null); + + if (premiereDate.HasValue) + { + var formatString = options.ReleaseDateFormat; + + await writer.WriteElementStringAsync( + null, + "aired", + null, + premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (item.IndexNumber.HasValue) + { + await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (item.ParentIndexNumber.HasValue) + { + await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + } + else + { + await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(item.Name)) + { + await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(item.OriginalTitle)) + { + await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false); + } + + if (item.PremiereDate.HasValue) + { + var formatString = options.ReleaseDateFormat; + + await writer.WriteElementStringAsync( + null, + "premiered", + null, + item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); + await writer.WriteElementStringAsync( + null, + "releasedate", + null, + item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + } + + await writer.WriteElementStringAsync( + null, + "dateadded", + null, + DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false); + + if (item.ProductionYear.HasValue) + { + await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(item.OfficialRating)) + { + await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false); + } + + var overview = (item.Overview ?? string.Empty) + .StripHtml() + .Replace(""", "'", StringComparison.Ordinal); + + await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false); + + if (item.CommunityRating.HasValue) + { + await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + foreach (var genre in item.Genres) + { + await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); + } + + var people = item.Id.IsEmpty() ? new List() : _libraryManager.GetPeople(item); + + var directors = people + .Where(i => i.IsType(PersonKind.Director)) + .Select(i => i.Name) + .ToList(); + + foreach (var person in directors) + { + await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false); + } + + var writers = people + .Where(i => i.IsType(PersonKind.Writer)) + .Select(i => i.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var person in writers) + { + await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false); + } + + foreach (var person in writers) + { + await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false); + } + + var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); + + if (!string.IsNullOrEmpty(tmdbCollection)) + { + await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false); + } + + var imdb = item.GetProviderId(MetadataProvider.Imdb); + if (!string.IsNullOrEmpty(imdb)) + { + if (!isSeriesEpisode) + { + await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false); + } + + await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + var tvdb = item.GetProviderId(MetadataProvider.Tvdb); + if (!string.IsNullOrEmpty(tvdb)) + { + await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + var tmdb = item.GetProviderId(MetadataProvider.Tmdb); + if (!string.IsNullOrEmpty(tmdb)) + { + await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + if (lockData) + { + await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false); + } + + if (item.CriticRating.HasValue) + { + await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(item.Tagline)) + { + await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false); + } + + foreach (var studio in item.Studios) + { + await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false); + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); + await writer.WriteEndDocumentAsync().ConfigureAwait(false); + } + } + } + + private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program) + { + var image = program.IsSeries ? + (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) : + (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0)); + + if (image is not null) + { + try + { + await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving recording image"); + } + } + + if (!program.IsSeries) + { + image = program.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving recording image"); + } + } + + image = program.GetImageInfo(ImageType.Thumb, 0); + if (image is not null) + { + try + { + await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving recording image"); + } + } + + image = program.GetImageInfo(ImageType.Logo, 0); + if (image is not null) + { + try + { + await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving recording image"); + } + } + } + } + + private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image) + { + if (!image.IsLocalFile) + { + image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false); + } + + var imageSaveFilenameWithoutExtension = image.Type switch + { + ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster", + ImageType.Logo => "logo", + ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape", + ImageType.Backdrop => "fanart", + _ => null + }; + + if (imageSaveFilenameWithoutExtension is null) + { + return; + } + + var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath)!, imageSaveFilenameWithoutExtension); + + // preserve original image extension + imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path)); + + File.Copy(image.Path, imageSavePath, true); + } +} -- 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 'src') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 550c16b4c..745753440 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 d0d5bb81c..d372277e0 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 7f4cad951..78dd7a71c 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 5adadec39..04f47b729 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 0ac0699a3..ed08cdc47 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 000000000..b918e2931 --- /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 d1688dfd9..06a0ea4e9 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 dc15d53ff..18ff6a949 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 d02be31cf..e247ecb44 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 394fbbaea..056bb6e6d 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 6b4ce6f7c..f7b9604af 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 ce9361089..c6874e4db 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 000000000..4ac205492 --- /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 33a9aca31..d5f6873a2 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 2b3867512..58b67ae55 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 0c1c269a4..8077bd791 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 170b8b2550a6ebb08453fe96d6c2223eaa1aa0ff Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 15 Feb 2024 10:47:59 -0500 Subject: Use WaitForExitAsync instead of Exited for recording cleanup --- .../Recordings/RecordingsManager.cs | 41 ++++++++-------------- 1 file changed, 15 insertions(+), 26 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs index 4ac205492..20f89ec8f 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -416,7 +416,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable timer.RecordingPath = recordingPath; timer.Status = RecordingStatus.Completed; _timerManager.AddOrUpdate(timer, false); - PostProcessRecording(recordingPath); + await PostProcessRecording(recordingPath).ConfigureAwait(false); } else { @@ -800,7 +800,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); } - private void PostProcessRecording(string path) + private async Task PostProcessRecording(string path) { var options = _config.GetLiveTvConfiguration(); if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) @@ -810,40 +810,29 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable try { - var process = new Process + using var process = new Process(); + process.StartInfo = new ProcessStartInfo { - 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 + Arguments = options.RecordingPostProcessorArguments + .Replace("{path}", path, StringComparison.OrdinalIgnoreCase), + CreateNoWindow = true, + ErrorDialog = false, + FileName = options.RecordingPostProcessor, + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false }; + process.EnableRaisingEvents = true; _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); - process.Exited += OnProcessExited; process.Start(); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + + _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); } 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); - } - } - } } -- cgit v1.2.3 From d96fec2330e8df69c5b765fbd712cf8347a593a9 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 15 Feb 2024 12:52:24 -0500 Subject: Move RecordingHelper to recordings folder --- src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs | 83 ---------------------- src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs | 81 +++++++++++++++++++++ src/Jellyfin.LiveTv/Timers/TimerManager.cs | 2 +- .../Jellyfin.LiveTv.Tests/RecordingHelperTests.cs | 2 +- 4 files changed, 83 insertions(+), 85 deletions(-) delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs create mode 100644 src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs (limited to 'src') diff --git a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs b/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs deleted file mode 100644 index 6bda231b2..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs +++ /dev/null @@ -1,83 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.Text; -using MediaBrowser.Controller.LiveTv; - -namespace Jellyfin.LiveTv.EmbyTV -{ - internal static class RecordingHelper - { - public static DateTime GetStartTime(TimerInfo timer) - { - return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds); - } - - public static string GetRecordingName(TimerInfo info) - { - var name = info.Name; - - if (info.IsProgramSeries) - { - var addHyphen = true; - - if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) - { - name += string.Format( - CultureInfo.InvariantCulture, - " S{0}E{1}", - info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), - info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); - addHyphen = false; - } - else if (info.OriginalAirDate.HasValue) - { - if (info.OriginalAirDate.Value.Date.Equals(info.StartDate.Date)) - { - name += " " + GetDateString(info.StartDate); - } - else - { - name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - } - } - else - { - name += " " + GetDateString(info.StartDate); - } - - if (!string.IsNullOrWhiteSpace(info.EpisodeTitle)) - { - var tmpName = name; - if (addHyphen) - { - tmpName += " -"; - } - - tmpName += " " + info.EpisodeTitle; - // Since the filename will be used with file ext. (.mp4, .ts, etc) - if (Encoding.UTF8.GetByteCount(tmpName) < 250) - { - name = tmpName; - } - } - } - else if (info.IsMovie && info.ProductionYear is not null) - { - name += " (" + info.ProductionYear + ")"; - } - else - { - name += " " + GetDateString(info.StartDate); - } - - return name; - } - - private static string GetDateString(DateTime date) - { - return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture); - } - } -} diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs new file mode 100644 index 000000000..1c8e2960b --- /dev/null +++ b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs @@ -0,0 +1,81 @@ +using System; +using System.Globalization; +using System.Text; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.Recordings +{ + internal static class RecordingHelper + { + public static DateTime GetStartTime(TimerInfo timer) + { + return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds); + } + + public static string GetRecordingName(TimerInfo info) + { + var name = info.Name; + + if (info.IsProgramSeries) + { + var addHyphen = true; + + if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) + { + name += string.Format( + CultureInfo.InvariantCulture, + " S{0}E{1}", + info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), + info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); + addHyphen = false; + } + else if (info.OriginalAirDate.HasValue) + { + if (info.OriginalAirDate.Value.Date.Equals(info.StartDate.Date)) + { + name += " " + GetDateString(info.StartDate); + } + else + { + name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + } + else + { + name += " " + GetDateString(info.StartDate); + } + + if (!string.IsNullOrWhiteSpace(info.EpisodeTitle)) + { + var tmpName = name; + if (addHyphen) + { + tmpName += " -"; + } + + tmpName += " " + info.EpisodeTitle; + // Since the filename will be used with file ext. (.mp4, .ts, etc) + if (Encoding.UTF8.GetByteCount(tmpName) < 250) + { + name = tmpName; + } + } + } + else if (info.IsMovie && info.ProductionYear is not null) + { + name += " (" + info.ProductionYear + ")"; + } + else + { + name += " " + GetDateString(info.StartDate); + } + + return name; + } + + private static string GetDateString(DateTime date) + { + return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture); + } + } +} diff --git a/src/Jellyfin.LiveTv/Timers/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs index 6bcbd3324..2e5003a53 100644 --- a/src/Jellyfin.LiveTv/Timers/TimerManager.cs +++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs @@ -7,7 +7,7 @@ using System.IO; using System.Linq; using System.Threading; using Jellyfin.Data.Events; -using Jellyfin.LiveTv.EmbyTV; +using Jellyfin.LiveTv.Recordings; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.LiveTv; diff --git a/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs index b4960dc0b..6a33a6699 100644 --- a/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs @@ -1,5 +1,5 @@ using System; -using Jellyfin.LiveTv.EmbyTV; +using Jellyfin.LiveTv.Recordings; using MediaBrowser.Controller.LiveTv; using Xunit; -- cgit v1.2.3 From 31f285480ae3b4573d2ddc18b50a8ed8b4160a41 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 15 Feb 2024 12:53:55 -0500 Subject: Move RecordingNotifier to recordings folder --- Jellyfin.Server/Startup.cs | 2 +- src/Jellyfin.LiveTv/RecordingNotifier.cs | 96 ---------------------- .../Recordings/RecordingNotifier.cs | 96 ++++++++++++++++++++++ 3 files changed, 97 insertions(+), 97 deletions(-) delete mode 100644 src/Jellyfin.LiveTv/RecordingNotifier.cs create mode 100644 src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs (limited to 'src') diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 558ad5b7b..51b34fd15 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -6,9 +6,9 @@ using System.Net.Mime; using System.Text; using Emby.Server.Implementations.EntryPoints; using Jellyfin.Api.Middleware; -using Jellyfin.LiveTv; using Jellyfin.LiveTv.EmbyTV; using Jellyfin.LiveTv.Extensions; +using Jellyfin.LiveTv.Recordings; using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking; using Jellyfin.Networking.HappyEyeballs; diff --git a/src/Jellyfin.LiveTv/RecordingNotifier.cs b/src/Jellyfin.LiveTv/RecordingNotifier.cs deleted file mode 100644 index 226d525e7..000000000 --- a/src/Jellyfin.LiveTv/RecordingNotifier.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Session; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.LiveTv -{ - /// - /// responsible for notifying users when a LiveTV recording is completed. - /// - public sealed class RecordingNotifier : IHostedService - { - private readonly ILogger _logger; - private readonly ISessionManager _sessionManager; - private readonly IUserManager _userManager; - private readonly ILiveTvManager _liveTvManager; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The . - /// The . - public RecordingNotifier( - ILogger logger, - ISessionManager sessionManager, - IUserManager userManager, - ILiveTvManager liveTvManager) - { - _logger = logger; - _sessionManager = sessionManager; - _userManager = userManager; - _liveTvManager = liveTvManager; - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _liveTvManager.TimerCancelled += OnLiveTvManagerTimerCancelled; - _liveTvManager.SeriesTimerCancelled += OnLiveTvManagerSeriesTimerCancelled; - _liveTvManager.TimerCreated += OnLiveTvManagerTimerCreated; - _liveTvManager.SeriesTimerCreated += OnLiveTvManagerSeriesTimerCreated; - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; - _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; - _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; - _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; - - return Task.CompletedTask; - } - - private async void OnLiveTvManagerSeriesTimerCreated(object? sender, GenericEventArgs e) - => await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); - - private async void OnLiveTvManagerTimerCreated(object? sender, GenericEventArgs e) - => await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); - - private async void OnLiveTvManagerSeriesTimerCancelled(object? sender, GenericEventArgs e) - => await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); - - private async void OnLiveTvManagerTimerCancelled(object? sender, GenericEventArgs e) - => await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); - - private async Task SendMessage(SessionMessageType name, TimerEventInfo info) - { - var users = _userManager.Users - .Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)) - .Select(i => i.Id) - .ToList(); - - try - { - await _sessionManager.SendMessageToUserSessions(users, name, info, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending message"); - } - } - } -} diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs new file mode 100644 index 000000000..e63afa626 --- /dev/null +++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs @@ -0,0 +1,96 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Recordings +{ + /// + /// responsible for notifying users when a LiveTV recording is completed. + /// + public sealed class RecordingNotifier : IHostedService + { + private readonly ILogger _logger; + private readonly ISessionManager _sessionManager; + private readonly IUserManager _userManager; + private readonly ILiveTvManager _liveTvManager; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + public RecordingNotifier( + ILogger logger, + ISessionManager sessionManager, + IUserManager userManager, + ILiveTvManager liveTvManager) + { + _logger = logger; + _sessionManager = sessionManager; + _userManager = userManager; + _liveTvManager = liveTvManager; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _liveTvManager.TimerCancelled += OnLiveTvManagerTimerCancelled; + _liveTvManager.SeriesTimerCancelled += OnLiveTvManagerSeriesTimerCancelled; + _liveTvManager.TimerCreated += OnLiveTvManagerTimerCreated; + _liveTvManager.SeriesTimerCreated += OnLiveTvManagerSeriesTimerCreated; + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; + _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; + _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; + _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; + + return Task.CompletedTask; + } + + private async void OnLiveTvManagerSeriesTimerCreated(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerTimerCreated(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerSeriesTimerCancelled(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerTimerCancelled(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); + + private async Task SendMessage(SessionMessageType name, TimerEventInfo info) + { + var users = _userManager.Users + .Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)) + .Select(i => i.Id) + .ToList(); + + try + { + await _sessionManager.SendMessageToUserSessions(users, name, info, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message"); + } + } + } +} -- cgit v1.2.3 From 3beb10747f229324fc8ad045347b6d1f6372dc31 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 21 Feb 2024 11:09:38 -0500 Subject: Move GetNfoConfiguration to LiveTvConfigurationExtensions --- .../Configuration/LiveTvConfigurationExtensions.cs | 9 +++++++++ .../EmbyTV/NfoConfigurationExtensions.cs | 19 ------------------- 2 files changed, 9 insertions(+), 19 deletions(-) delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs index 67d0e5295..f7888496f 100644 --- a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs +++ b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs @@ -1,4 +1,5 @@ using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.LiveTv; namespace Jellyfin.LiveTv.Configuration; @@ -15,4 +16,12 @@ public static class LiveTvConfigurationExtensions /// The . public static LiveTvOptions GetLiveTvConfiguration(this IConfigurationManager configurationManager) => configurationManager.GetConfiguration("livetv"); + + /// + /// Gets the . + /// + /// The . + /// The . + public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager) + => configurationManager.GetConfiguration("xbmcmetadata"); } diff --git a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs deleted file mode 100644 index e8570f0e0..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Configuration; - -namespace Jellyfin.LiveTv.EmbyTV -{ - /// - /// Class containing extension methods for working with the nfo configuration. - /// - public static class NfoConfigurationExtensions - { - /// - /// Gets the nfo configuration. - /// - /// The configuration manager. - /// The nfo configuration. - public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager) - => configurationManager.GetConfiguration("xbmcmetadata"); - } -} -- cgit v1.2.3 From fa6d859a5146013c54a4677a50f2fccbcc6afd02 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 21 Feb 2024 11:10:57 -0500 Subject: Rename LiveTvHost to RecordingsHost and move to recordings folder --- Jellyfin.Server/Startup.cs | 2 +- src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs | 37 ------------------------ src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs | 37 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 38 deletions(-) delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs create mode 100644 src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs (limited to 'src') diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 51b34fd15..6728cd0b4 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -128,7 +128,7 @@ namespace Jellyfin.Server services.AddHlsPlaylistGenerator(); services.AddLiveTvServices(); - services.AddHostedService(); + services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); diff --git a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs deleted file mode 100644 index 18ff6a949..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.LiveTv.Timers; -using MediaBrowser.Controller.LiveTv; -using Microsoft.Extensions.Hosting; - -namespace Jellyfin.LiveTv.EmbyTV; - -/// -/// responsible for initializing Live TV. -/// -public sealed class LiveTvHost : IHostedService -{ - private readonly IRecordingsManager _recordingsManager; - private readonly TimerManager _timerManager; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - public LiveTvHost(IRecordingsManager recordingsManager, TimerManager timerManager) - { - _recordingsManager = recordingsManager; - _timerManager = timerManager; - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _timerManager.RestartTimers(); - return _recordingsManager.CreateRecordingFolders(); - } - - /// - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs new file mode 100644 index 000000000..f4daa0975 --- /dev/null +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.Timers; +using MediaBrowser.Controller.LiveTv; +using Microsoft.Extensions.Hosting; + +namespace Jellyfin.LiveTv.Recordings; + +/// +/// responsible for Live TV recordings. +/// +public sealed class RecordingsHost : IHostedService +{ + private readonly IRecordingsManager _recordingsManager; + private readonly TimerManager _timerManager; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public RecordingsHost(IRecordingsManager recordingsManager, TimerManager timerManager) + { + _recordingsManager = recordingsManager; + _timerManager = timerManager; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _timerManager.RestartTimers(); + return _recordingsManager.CreateRecordingFolders(); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} -- cgit v1.2.3 From cac7ff84ca407d7f452f1ea988f472118012f6da Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 21 Feb 2024 11:12:49 -0500 Subject: Rename EmbyTV to DefaultLiveTvService --- Jellyfin.Server/Startup.cs | 1 - src/Jellyfin.LiveTv/DefaultLiveTvService.cs | 998 +++++++++++++++++++++ src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 998 --------------------- .../LiveTvServiceCollectionExtensions.cs | 2 +- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 2 +- src/Jellyfin.LiveTv/LiveTvManager.cs | 10 +- .../Recordings/RecordingsManager.cs | 1 - .../Recordings/RecordingsMetadataManager.cs | 1 - 8 files changed, 1005 insertions(+), 1008 deletions(-) create mode 100644 src/Jellyfin.LiveTv/DefaultLiveTvService.cs delete mode 100644 src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs (limited to 'src') diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 6728cd0b4..e9fb3e4c2 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -6,7 +6,6 @@ using System.Net.Mime; using System.Text; using Emby.Server.Implementations.EntryPoints; using Jellyfin.Api.Middleware; -using Jellyfin.LiveTv.EmbyTV; using Jellyfin.LiveTv.Extensions; using Jellyfin.LiveTv.Recordings; using Jellyfin.MediaEncoding.Hls.Extensions; diff --git a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs new file mode 100644 index 000000000..318cc7acd --- /dev/null +++ b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs @@ -0,0 +1,998 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using Jellyfin.Extensions; +using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Timers; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv +{ + public sealed class DefaultLiveTvService : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds + { + public const string ServiceName = "Emby"; + + private readonly ILogger _logger; + private readonly IServerConfigurationManager _config; + private readonly ITunerHostManager _tunerHostManager; + private readonly IListingsManager _listingsManager; + private readonly IRecordingsManager _recordingsManager; + private readonly ILibraryManager _libraryManager; + private readonly LiveTvDtoService _tvDtoService; + private readonly TimerManager _timerManager; + private readonly SeriesTimerManager _seriesTimerManager; + + public DefaultLiveTvService( + ILogger logger, + IServerConfigurationManager config, + ITunerHostManager tunerHostManager, + IListingsManager listingsManager, + IRecordingsManager recordingsManager, + ILibraryManager libraryManager, + LiveTvDtoService tvDtoService, + TimerManager timerManager, + SeriesTimerManager seriesTimerManager) + { + _logger = logger; + _config = config; + _libraryManager = libraryManager; + _tunerHostManager = tunerHostManager; + _listingsManager = listingsManager; + _recordingsManager = recordingsManager; + _tvDtoService = tvDtoService; + _timerManager = timerManager; + _seriesTimerManager = seriesTimerManager; + + _timerManager.TimerFired += OnTimerManagerTimerFired; + } + + public event EventHandler> TimerCreated; + + public event EventHandler> TimerCancelled; + + /// + public string Name => ServiceName; + + /// + public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; + + public async Task RefreshSeriesTimers(CancellationToken cancellationToken) + { + var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); + + foreach (var timer in seriesTimers) + { + UpdateTimersForSeriesTimer(timer, false, true); + } + } + + public async Task RefreshTimers(CancellationToken cancellationToken) + { + var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); + + var tempChannelCache = new Dictionary(); + + foreach (var timer in timers) + { + if (DateTime.UtcNow > timer.EndDate && _recordingsManager.GetActiveRecordingPath(timer.Id) is null) + { + _timerManager.Delete(timer); + continue; + } + + if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId)) + { + continue; + } + + var program = GetProgramInfoFromCache(timer); + if (program is null) + { + _timerManager.Delete(timer); + continue; + } + + CopyProgramInfoToTimerInfo(program, timer, tempChannelCache); + _timerManager.Update(timer); + } + } + + private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) + { + var channels = new List(); + + foreach (var hostInstance in _tunerHostManager.TunerHosts) + { + try + { + var tunerChannels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); + + channels.AddRange(tunerChannels); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channels"); + } + } + + await _listingsManager.AddProviderMetadata(channels, enableCache, cancellationToken).ConfigureAwait(false); + + return channels; + } + + public Task> GetChannelsAsync(CancellationToken cancellationToken) + { + return GetChannelsAsync(false, cancellationToken); + } + + public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) + { + var timers = _timerManager + .GetAll() + .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var timer in timers) + { + CancelTimerInternal(timer.Id, true, true); + } + + var remove = _seriesTimerManager.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (remove is not null) + { + _seriesTimerManager.Delete(remove); + } + + return Task.CompletedTask; + } + + private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation) + { + var timer = _timerManager.GetTimer(timerId); + if (timer is not null) + { + var statusChanging = timer.Status != RecordingStatus.Cancelled; + timer.Status = RecordingStatus.Cancelled; + + if (isManualCancellation) + { + timer.IsManual = true; + } + + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) + { + _timerManager.Delete(timer); + } + else + { + _timerManager.AddOrUpdate(timer, false); + } + + if (statusChanging && TimerCancelled is not null) + { + TimerCancelled(this, new GenericEventArgs(timerId)); + } + } + + _recordingsManager.CancelRecording(timerId, timer); + } + + public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) + { + CancelTimerInternal(timerId, false, true); + return Task.CompletedTask; + } + + public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CreateTimer(TimerInfo info, CancellationToken cancellationToken) + { + var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? + null : + _timerManager.GetTimerByProgramId(info.ProgramId); + + if (existingTimer is not null) + { + if (existingTimer.Status == RecordingStatus.Cancelled + || existingTimer.Status == RecordingStatus.Completed) + { + existingTimer.Status = RecordingStatus.New; + existingTimer.IsManual = true; + _timerManager.Update(existingTimer); + return Task.FromResult(existingTimer.Id); + } + + throw new ArgumentException("A scheduled recording already exists for this program."); + } + + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + + LiveTvProgram programInfo = null; + + if (!string.IsNullOrWhiteSpace(info.ProgramId)) + { + programInfo = GetProgramInfoFromCache(info); + } + + if (programInfo is null) + { + _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); + programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); + } + + if (programInfo is not null) + { + CopyProgramInfoToTimerInfo(programInfo, info); + } + + info.IsManual = true; + _timerManager.Add(info); + + TimerCreated?.Invoke(this, new GenericEventArgs(info)); + + return Task.FromResult(info.Id); + } + + public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) + { + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + + // populate info.seriesID + var program = GetProgramInfoFromCache(info.ProgramId); + + if (program is not null) + { + info.SeriesId = program.ExternalSeriesId; + } + else + { + throw new InvalidOperationException("SeriesId for program not found"); + } + + // If any timers have already been manually created, make sure they don't get cancelled + var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false)) + .Where(i => + { + if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId)) + { + return true; + } + + if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId)) + { + return true; + } + + return false; + }) + .ToList(); + + _seriesTimerManager.Add(info); + + foreach (var timer in existingTimers) + { + timer.SeriesTimerId = info.Id; + timer.IsManual = true; + + _timerManager.AddOrUpdate(timer, false); + } + + UpdateTimersForSeriesTimer(info, true, false); + + return info.Id; + } + + public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + var instance = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (instance is not null) + { + instance.ChannelId = info.ChannelId; + instance.Days = info.Days; + instance.EndDate = info.EndDate; + instance.IsPostPaddingRequired = info.IsPostPaddingRequired; + instance.IsPrePaddingRequired = info.IsPrePaddingRequired; + instance.PostPaddingSeconds = info.PostPaddingSeconds; + instance.PrePaddingSeconds = info.PrePaddingSeconds; + instance.Priority = info.Priority; + instance.RecordAnyChannel = info.RecordAnyChannel; + instance.RecordAnyTime = info.RecordAnyTime; + instance.RecordNewOnly = info.RecordNewOnly; + instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary; + instance.KeepUpTo = info.KeepUpTo; + instance.KeepUntil = info.KeepUntil; + instance.StartDate = info.StartDate; + + _seriesTimerManager.Update(instance); + + UpdateTimersForSeriesTimer(instance, true, true); + } + + return Task.CompletedTask; + } + + public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) + { + var existingTimer = _timerManager.GetTimer(updatedTimer.Id); + + if (existingTimer is null) + { + throw new ResourceNotFoundException(); + } + + // Only update if not currently active + if (_recordingsManager.GetActiveRecordingPath(updatedTimer.Id) is null) + { + existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; + existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; + existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; + + _timerManager.Update(existingTimer); + } + + return Task.CompletedTask; + } + + private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer) + { + // Update the program info but retain the status + existingTimer.ChannelId = updatedTimer.ChannelId; + existingTimer.CommunityRating = updatedTimer.CommunityRating; + existingTimer.EndDate = updatedTimer.EndDate; + existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber; + existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle; + existingTimer.Genres = updatedTimer.Genres; + existingTimer.IsMovie = updatedTimer.IsMovie; + existingTimer.IsSeries = updatedTimer.IsSeries; + existingTimer.Tags = updatedTimer.Tags; + existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; + existingTimer.IsRepeat = updatedTimer.IsRepeat; + existingTimer.Name = updatedTimer.Name; + existingTimer.OfficialRating = updatedTimer.OfficialRating; + existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate; + existingTimer.Overview = updatedTimer.Overview; + existingTimer.ProductionYear = updatedTimer.ProductionYear; + existingTimer.ProgramId = updatedTimer.ProgramId; + existingTimer.SeasonNumber = updatedTimer.SeasonNumber; + existingTimer.StartDate = updatedTimer.StartDate; + existingTimer.ShowId = updatedTimer.ShowId; + existingTimer.ProviderIds = updatedTimer.ProviderIds; + existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; + } + + public Task> GetTimersAsync(CancellationToken cancellationToken) + { + var excludeStatues = new List + { + RecordingStatus.Completed + }; + + var timers = _timerManager.GetAll() + .Where(i => !excludeStatues.Contains(i.Status)); + + return Task.FromResult(timers); + } + + public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) + { + var config = _config.GetLiveTvConfiguration(); + + var defaults = new SeriesTimerInfo() + { + PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), + PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), + RecordAnyChannel = false, + RecordAnyTime = true, + RecordNewOnly = true, + + Days = new List + { + DayOfWeek.Sunday, + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday + } + }; + + if (program is not null) + { + defaults.SeriesId = program.SeriesId; + defaults.ProgramId = program.Id; + defaults.RecordNewOnly = !program.IsRepeat; + defaults.Name = program.Name; + } + + defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; + defaults.KeepUntil = KeepUntil.UntilDeleted; + + return Task.FromResult(defaults); + } + + public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable)_seriesTimerManager.GetAll()); + } + + 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)); + + return await _listingsManager.GetProgramsAsync(channel, startDateUtc, endDateUtc, cancellationToken) + .ConfigureAwait(false); + } + + public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public async Task GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken) + { + _logger.LogInformation("Streaming Channel {Id}", channelId); + + var result = string.IsNullOrEmpty(streamId) ? + null : + currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); + + if (result is not null && result.EnableStreamSharing) + { + result.ConsumerCount++; + + _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); + + return result; + } + + foreach (var hostInstance in _tunerHostManager.TunerHosts) + { + try + { + result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + + var openedMediaSource = result.MediaSource; + + result.OriginalStreamId = streamId; + + _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); + + return result; + } + catch (FileNotFoundException) + { + } + catch (OperationCanceledException) + { + } + } + + throw new ResourceNotFoundException("Tuner not found."); + } + + public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(channelId)) + { + throw new ArgumentNullException(nameof(channelId)); + } + + foreach (var hostInstance in _tunerHostManager.TunerHosts) + { + try + { + var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false); + + if (sources.Count > 0) + { + return sources; + } + } + catch (NotImplementedException) + { + } + } + + throw new NotImplementedException(); + } + + public Task CloseLiveStream(string id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task ResetTuner(string id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async void OnTimerManagerTimerFired(object sender, GenericEventArgs e) + { + var timer = e.Argument; + + _logger.LogInformation("Recording timer fired for {0}.", timer.Name); + + 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); + _timerManager.Delete(timer); + return; + } + + var activeRecordingInfo = new ActiveRecordingInfo + { + CancellationTokenSource = new CancellationTokenSource(), + Timer = timer, + Id = timer.Id + }; + + if (_recordingsManager.GetActiveRecordingPath(timer.Id) is not null) + { + _logger.LogInformation("Skipping RecordStream because it's already in progress."); + return; + } + + 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); + } + + await _recordingsManager.RecordStream(activeRecordingInfo, GetLiveTvChannel(timer), recordingEndDate) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording stream"); + } + } + + private BaseItem GetLiveTvChannel(TimerInfo timer) + { + var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId); + return _libraryManager.GetItemById(internalChannelId); + } + + private LiveTvProgram GetProgramInfoFromCache(string programId) + { + var query = new InternalItemsQuery + { + ItemIds = [_tvDtoService.GetInternalProgramId(programId)], + Limit = 1, + DtoOptions = new DtoOptions() + }; + + return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); + } + + private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer) + { + return GetProgramInfoFromCache(timer.ProgramId); + } + + private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc) + { + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + Limit = 1, + DtoOptions = new DtoOptions(true) + { + EnableImages = false + }, + MinStartDate = startDateUtc.AddMinutes(-3), + MaxStartDate = startDateUtc.AddMinutes(3), + OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) } + }; + + if (!string.IsNullOrWhiteSpace(channelId)) + { + query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)]; + } + + return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); + } + + private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) + { + if (timer.IsManual) + { + return false; + } + + if (!seriesTimer.RecordAnyTime + && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) + { + return true; + } + + if (seriesTimer.RecordNewOnly && timer.IsRepeat) + { + return true; + } + + if (!seriesTimer.RecordAnyChannel + && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer); + } + + private void HandleDuplicateShowIds(List timers) + { + // sort showings by HD channels first, then by startDate, record earliest showing possible + foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1)) + { + timer.Status = RecordingStatus.Cancelled; + _timerManager.Update(timer); + } + } + + private void SearchForDuplicateShowIds(IEnumerable timers) + { + var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList(); + + foreach (var group in groups) + { + if (string.IsNullOrWhiteSpace(group.Key)) + { + continue; + } + + var groupTimers = group.ToList(); + + if (groupTimers.Count < 2) + { + continue; + } + + // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856 + if (group.Key.EndsWith("0000", StringComparison.Ordinal)) + { + continue; + } + + HandleDuplicateShowIds(groupTimers); + } + } + + private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers) + { + var allTimers = GetTimersForSeries(seriesTimer).ToList(); + + var enabledTimersForSeries = new List(); + foreach (var timer in allTimers) + { + var existingTimer = _timerManager.GetTimer(timer.Id) + ?? (string.IsNullOrWhiteSpace(timer.ProgramId) + ? null + : _timerManager.GetTimerByProgramId(timer.ProgramId)); + + if (existingTimer is null) + { + if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) + { + timer.Status = RecordingStatus.Cancelled; + } + else + { + enabledTimersForSeries.Add(timer); + } + + _timerManager.Add(timer); + + TimerCreated?.Invoke(this, new GenericEventArgs(timer)); + } + + // 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 (_recordingsManager.GetActiveRecordingPath(timer.Id) is null + && _recordingsManager.GetActiveRecordingPath(existingTimer.Id) is null) + { + UpdateExistingTimerWithNewMetadata(existingTimer, timer); + + // Needed by ShouldCancelTimerForSeriesTimer + timer.IsManual = existingTimer.IsManual; + + if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) + { + existingTimer.Status = RecordingStatus.Cancelled; + } + else if (!existingTimer.IsManual) + { + existingTimer.Status = RecordingStatus.New; + } + + if (existingTimer.Status != RecordingStatus.Cancelled) + { + enabledTimersForSeries.Add(existingTimer); + } + + if (updateTimerSettings) + { + existingTimer.KeepUntil = seriesTimer.KeepUntil; + existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; + existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; + existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; + existingTimer.Priority = seriesTimer.Priority; + existingTimer.SeriesTimerId = seriesTimer.Id; + } + + existingTimer.SeriesTimerId = seriesTimer.Id; + _timerManager.Update(existingTimer); + } + } + + SearchForDuplicateShowIds(enabledTimersForSeries); + + if (deleteInvalidTimers) + { + var allTimerIds = allTimers + .Select(i => i.Id) + .ToList(); + + var deleteStatuses = new[] + { + RecordingStatus.New + }; + + var deletes = _timerManager.GetAll() + .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) + .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) + .Where(i => deleteStatuses.Contains(i.Status)) + .ToList(); + + foreach (var timer in deletes) + { + CancelTimerInternal(timer.Id, false, false); + } + } + } + + private IEnumerable GetTimersForSeries(SeriesTimerInfo seriesTimer) + { + ArgumentNullException.ThrowIfNull(seriesTimer); + + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ExternalSeriesId = seriesTimer.SeriesId, + DtoOptions = new DtoOptions(true) + { + EnableImages = false + }, + MinEndDate = DateTime.UtcNow + }; + + if (string.IsNullOrEmpty(seriesTimer.SeriesId)) + { + query.Name = seriesTimer.Name; + } + + if (!seriesTimer.RecordAnyChannel) + { + query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)]; + } + + var tempChannelCache = new Dictionary(); + + return _libraryManager.GetItemList(query).Cast().Select(i => CreateTimer(i, seriesTimer, tempChannelCache)); + } + + private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary tempChannelCache) + { + string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; + + if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.IsEmpty()) + { + if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) + { + channel = _libraryManager.GetItemList( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + ItemIds = new[] { parent.ChannelId }, + DtoOptions = new DtoOptions() + }).FirstOrDefault() as LiveTvChannel; + + if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) + { + tempChannelCache[parent.ChannelId] = channel; + } + } + + if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel)) + { + channelId = channel.ExternalId; + } + } + + var timer = new TimerInfo + { + ChannelId = channelId, + Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture), + StartDate = parent.StartDate, + EndDate = parent.EndDate.Value, + ProgramId = parent.ExternalId, + PrePaddingSeconds = seriesTimer.PrePaddingSeconds, + PostPaddingSeconds = seriesTimer.PostPaddingSeconds, + IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired, + IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired, + KeepUntil = seriesTimer.KeepUntil, + Priority = seriesTimer.Priority, + Name = parent.Name, + Overview = parent.Overview, + SeriesId = parent.ExternalSeriesId, + SeriesTimerId = seriesTimer.Id, + ShowId = parent.ShowId + }; + + CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache); + + return timer; + } + + private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo) + { + var tempChannelCache = new Dictionary(); + CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache); + } + + private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary tempChannelCache) + { + string channelId = null; + + if (!programInfo.ChannelId.IsEmpty()) + { + if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) + { + channel = _libraryManager.GetItemList( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + ItemIds = new[] { programInfo.ChannelId }, + DtoOptions = new DtoOptions() + }).FirstOrDefault() as LiveTvChannel; + + if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) + { + tempChannelCache[programInfo.ChannelId] = channel; + } + } + + if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel)) + { + channelId = channel.ExternalId; + } + } + + timerInfo.Name = programInfo.Name; + timerInfo.StartDate = programInfo.StartDate; + timerInfo.EndDate = programInfo.EndDate.Value; + + if (!string.IsNullOrWhiteSpace(channelId)) + { + timerInfo.ChannelId = channelId; + } + + timerInfo.SeasonNumber = programInfo.ParentIndexNumber; + timerInfo.EpisodeNumber = programInfo.IndexNumber; + timerInfo.IsMovie = programInfo.IsMovie; + timerInfo.ProductionYear = programInfo.ProductionYear; + timerInfo.EpisodeTitle = programInfo.EpisodeTitle; + timerInfo.OriginalAirDate = programInfo.PremiereDate; + timerInfo.IsProgramSeries = programInfo.IsSeries; + + timerInfo.IsSeries = programInfo.IsSeries; + + timerInfo.CommunityRating = programInfo.CommunityRating; + timerInfo.Overview = programInfo.Overview; + timerInfo.OfficialRating = programInfo.OfficialRating; + timerInfo.IsRepeat = programInfo.IsRepeat; + timerInfo.SeriesId = programInfo.ExternalSeriesId; + timerInfo.ProviderIds = programInfo.ProviderIds; + timerInfo.Tags = programInfo.Tags; + + var seriesProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var providerId in timerInfo.ProviderIds) + { + const string Search = "Series"; + if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase)) + { + seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value; + } + } + + timerInfo.SeriesProviderIds = seriesProviderIds; + } + + private bool IsProgramAlreadyInLibrary(TimerInfo program) + { + if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) + { + var seriesIds = _libraryManager.GetItemIds( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = program.Name + }).ToArray(); + + if (seriesIds.Length == 0) + { + return false; + } + + if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) + { + var result = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Episode }, + ParentIndexNumber = program.SeasonNumber.Value, + IndexNumber = program.EpisodeNumber.Value, + AncestorIds = seriesIds, + IsVirtualItem = false, + Limit = 1 + }); + + if (result.Count > 0) + { + return true; + } + } + } + + return false; + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs deleted file mode 100644 index 06a0ea4e9..000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ /dev/null @@ -1,998 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using Jellyfin.Extensions; -using Jellyfin.LiveTv.Configuration; -using Jellyfin.LiveTv.Timers; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds - { - public const string ServiceName = "Emby"; - - private readonly ILogger _logger; - private readonly IServerConfigurationManager _config; - private readonly ITunerHostManager _tunerHostManager; - private readonly IListingsManager _listingsManager; - private readonly IRecordingsManager _recordingsManager; - private readonly ILibraryManager _libraryManager; - private readonly LiveTvDtoService _tvDtoService; - private readonly TimerManager _timerManager; - private readonly SeriesTimerManager _seriesTimerManager; - - public EmbyTV( - ILogger logger, - IServerConfigurationManager config, - ITunerHostManager tunerHostManager, - IListingsManager listingsManager, - IRecordingsManager recordingsManager, - ILibraryManager libraryManager, - LiveTvDtoService tvDtoService, - TimerManager timerManager, - SeriesTimerManager seriesTimerManager) - { - _logger = logger; - _config = config; - _libraryManager = libraryManager; - _tunerHostManager = tunerHostManager; - _listingsManager = listingsManager; - _recordingsManager = recordingsManager; - _tvDtoService = tvDtoService; - _timerManager = timerManager; - _seriesTimerManager = seriesTimerManager; - - _timerManager.TimerFired += OnTimerManagerTimerFired; - } - - public event EventHandler> TimerCreated; - - public event EventHandler> TimerCancelled; - - /// - public string Name => ServiceName; - - /// - public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; - - public async Task RefreshSeriesTimers(CancellationToken cancellationToken) - { - var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); - - foreach (var timer in seriesTimers) - { - UpdateTimersForSeriesTimer(timer, false, true); - } - } - - public async Task RefreshTimers(CancellationToken cancellationToken) - { - var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); - - var tempChannelCache = new Dictionary(); - - foreach (var timer in timers) - { - if (DateTime.UtcNow > timer.EndDate && _recordingsManager.GetActiveRecordingPath(timer.Id) is null) - { - _timerManager.Delete(timer); - continue; - } - - if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId)) - { - continue; - } - - var program = GetProgramInfoFromCache(timer); - if (program is null) - { - _timerManager.Delete(timer); - continue; - } - - CopyProgramInfoToTimerInfo(program, timer, tempChannelCache); - _timerManager.Update(timer); - } - } - - private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) - { - var channels = new List(); - - foreach (var hostInstance in _tunerHostManager.TunerHosts) - { - try - { - var tunerChannels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); - - channels.AddRange(tunerChannels); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channels"); - } - } - - await _listingsManager.AddProviderMetadata(channels, enableCache, cancellationToken).ConfigureAwait(false); - - return channels; - } - - public Task> GetChannelsAsync(CancellationToken cancellationToken) - { - return GetChannelsAsync(false, cancellationToken); - } - - public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) - { - var timers = _timerManager - .GetAll() - .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var timer in timers) - { - CancelTimerInternal(timer.Id, true, true); - } - - var remove = _seriesTimerManager.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); - if (remove is not null) - { - _seriesTimerManager.Delete(remove); - } - - return Task.CompletedTask; - } - - private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation) - { - var timer = _timerManager.GetTimer(timerId); - if (timer is not null) - { - var statusChanging = timer.Status != RecordingStatus.Cancelled; - timer.Status = RecordingStatus.Cancelled; - - if (isManualCancellation) - { - timer.IsManual = true; - } - - if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) - { - _timerManager.Delete(timer); - } - else - { - _timerManager.AddOrUpdate(timer, false); - } - - if (statusChanging && TimerCancelled is not null) - { - TimerCancelled(this, new GenericEventArgs(timerId)); - } - } - - _recordingsManager.CancelRecording(timerId, timer); - } - - public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) - { - CancelTimerInternal(timerId, false, true); - return Task.CompletedTask; - } - - public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CreateTimer(TimerInfo info, CancellationToken cancellationToken) - { - var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? - null : - _timerManager.GetTimerByProgramId(info.ProgramId); - - if (existingTimer is not null) - { - if (existingTimer.Status == RecordingStatus.Cancelled - || existingTimer.Status == RecordingStatus.Completed) - { - existingTimer.Status = RecordingStatus.New; - existingTimer.IsManual = true; - _timerManager.Update(existingTimer); - return Task.FromResult(existingTimer.Id); - } - - throw new ArgumentException("A scheduled recording already exists for this program."); - } - - info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - - LiveTvProgram programInfo = null; - - if (!string.IsNullOrWhiteSpace(info.ProgramId)) - { - programInfo = GetProgramInfoFromCache(info); - } - - if (programInfo is null) - { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); - programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); - } - - if (programInfo is not null) - { - CopyProgramInfoToTimerInfo(programInfo, info); - } - - info.IsManual = true; - _timerManager.Add(info); - - TimerCreated?.Invoke(this, new GenericEventArgs(info)); - - return Task.FromResult(info.Id); - } - - public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) - { - info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - - // populate info.seriesID - var program = GetProgramInfoFromCache(info.ProgramId); - - if (program is not null) - { - info.SeriesId = program.ExternalSeriesId; - } - else - { - throw new InvalidOperationException("SeriesId for program not found"); - } - - // If any timers have already been manually created, make sure they don't get cancelled - var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false)) - .Where(i => - { - if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId)) - { - return true; - } - - if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId)) - { - return true; - } - - return false; - }) - .ToList(); - - _seriesTimerManager.Add(info); - - foreach (var timer in existingTimers) - { - timer.SeriesTimerId = info.Id; - timer.IsManual = true; - - _timerManager.AddOrUpdate(timer, false); - } - - UpdateTimersForSeriesTimer(info, true, false); - - return info.Id; - } - - public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) - { - var instance = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); - - if (instance is not null) - { - instance.ChannelId = info.ChannelId; - instance.Days = info.Days; - instance.EndDate = info.EndDate; - instance.IsPostPaddingRequired = info.IsPostPaddingRequired; - instance.IsPrePaddingRequired = info.IsPrePaddingRequired; - instance.PostPaddingSeconds = info.PostPaddingSeconds; - instance.PrePaddingSeconds = info.PrePaddingSeconds; - instance.Priority = info.Priority; - instance.RecordAnyChannel = info.RecordAnyChannel; - instance.RecordAnyTime = info.RecordAnyTime; - instance.RecordNewOnly = info.RecordNewOnly; - instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary; - instance.KeepUpTo = info.KeepUpTo; - instance.KeepUntil = info.KeepUntil; - instance.StartDate = info.StartDate; - - _seriesTimerManager.Update(instance); - - UpdateTimersForSeriesTimer(instance, true, true); - } - - return Task.CompletedTask; - } - - public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) - { - var existingTimer = _timerManager.GetTimer(updatedTimer.Id); - - if (existingTimer is null) - { - throw new ResourceNotFoundException(); - } - - // Only update if not currently active - if (_recordingsManager.GetActiveRecordingPath(updatedTimer.Id) is null) - { - existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; - existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; - existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; - existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; - - _timerManager.Update(existingTimer); - } - - return Task.CompletedTask; - } - - private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer) - { - // Update the program info but retain the status - existingTimer.ChannelId = updatedTimer.ChannelId; - existingTimer.CommunityRating = updatedTimer.CommunityRating; - existingTimer.EndDate = updatedTimer.EndDate; - existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber; - existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle; - existingTimer.Genres = updatedTimer.Genres; - existingTimer.IsMovie = updatedTimer.IsMovie; - existingTimer.IsSeries = updatedTimer.IsSeries; - existingTimer.Tags = updatedTimer.Tags; - existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; - existingTimer.IsRepeat = updatedTimer.IsRepeat; - existingTimer.Name = updatedTimer.Name; - existingTimer.OfficialRating = updatedTimer.OfficialRating; - existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate; - existingTimer.Overview = updatedTimer.Overview; - existingTimer.ProductionYear = updatedTimer.ProductionYear; - existingTimer.ProgramId = updatedTimer.ProgramId; - existingTimer.SeasonNumber = updatedTimer.SeasonNumber; - existingTimer.StartDate = updatedTimer.StartDate; - existingTimer.ShowId = updatedTimer.ShowId; - existingTimer.ProviderIds = updatedTimer.ProviderIds; - existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; - } - - public Task> GetTimersAsync(CancellationToken cancellationToken) - { - var excludeStatues = new List - { - RecordingStatus.Completed - }; - - var timers = _timerManager.GetAll() - .Where(i => !excludeStatues.Contains(i.Status)); - - return Task.FromResult(timers); - } - - public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) - { - var config = _config.GetLiveTvConfiguration(); - - var defaults = new SeriesTimerInfo() - { - PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), - PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), - RecordAnyChannel = false, - RecordAnyTime = true, - RecordNewOnly = true, - - Days = new List - { - DayOfWeek.Sunday, - DayOfWeek.Monday, - DayOfWeek.Tuesday, - DayOfWeek.Wednesday, - DayOfWeek.Thursday, - DayOfWeek.Friday, - DayOfWeek.Saturday - } - }; - - if (program is not null) - { - defaults.SeriesId = program.SeriesId; - defaults.ProgramId = program.Id; - defaults.RecordNewOnly = !program.IsRepeat; - defaults.Name = program.Name; - } - - defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; - defaults.KeepUntil = KeepUntil.UntilDeleted; - - return Task.FromResult(defaults); - } - - public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) - { - return Task.FromResult((IEnumerable)_seriesTimerManager.GetAll()); - } - - 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)); - - return await _listingsManager.GetProgramsAsync(channel, startDateUtc, endDateUtc, cancellationToken) - .ConfigureAwait(false); - } - - public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken) - { - _logger.LogInformation("Streaming Channel {Id}", channelId); - - var result = string.IsNullOrEmpty(streamId) ? - null : - currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); - - if (result is not null && result.EnableStreamSharing) - { - result.ConsumerCount++; - - _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); - - return result; - } - - foreach (var hostInstance in _tunerHostManager.TunerHosts) - { - try - { - result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false); - - var openedMediaSource = result.MediaSource; - - result.OriginalStreamId = streamId; - - _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); - - return result; - } - catch (FileNotFoundException) - { - } - catch (OperationCanceledException) - { - } - } - - throw new ResourceNotFoundException("Tuner not found."); - } - - public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(channelId)) - { - throw new ArgumentNullException(nameof(channelId)); - } - - foreach (var hostInstance in _tunerHostManager.TunerHosts) - { - try - { - var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false); - - if (sources.Count > 0) - { - return sources; - } - } - catch (NotImplementedException) - { - } - } - - throw new NotImplementedException(); - } - - public Task CloseLiveStream(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task ResetTuner(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - private async void OnTimerManagerTimerFired(object sender, GenericEventArgs e) - { - var timer = e.Argument; - - _logger.LogInformation("Recording timer fired for {0}.", timer.Name); - - 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); - _timerManager.Delete(timer); - return; - } - - var activeRecordingInfo = new ActiveRecordingInfo - { - CancellationTokenSource = new CancellationTokenSource(), - Timer = timer, - Id = timer.Id - }; - - if (_recordingsManager.GetActiveRecordingPath(timer.Id) is not null) - { - _logger.LogInformation("Skipping RecordStream because it's already in progress."); - return; - } - - 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); - } - - await _recordingsManager.RecordStream(activeRecordingInfo, GetLiveTvChannel(timer), recordingEndDate) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording stream"); - } - } - - private BaseItem GetLiveTvChannel(TimerInfo timer) - { - var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId); - return _libraryManager.GetItemById(internalChannelId); - } - - private LiveTvProgram GetProgramInfoFromCache(string programId) - { - var query = new InternalItemsQuery - { - ItemIds = [_tvDtoService.GetInternalProgramId(programId)], - Limit = 1, - DtoOptions = new DtoOptions() - }; - - return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); - } - - private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer) - { - return GetProgramInfoFromCache(timer.ProgramId); - } - - private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc) - { - var query = new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - Limit = 1, - DtoOptions = new DtoOptions(true) - { - EnableImages = false - }, - MinStartDate = startDateUtc.AddMinutes(-3), - MaxStartDate = startDateUtc.AddMinutes(3), - OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) } - }; - - if (!string.IsNullOrWhiteSpace(channelId)) - { - query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)]; - } - - return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); - } - - private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) - { - if (timer.IsManual) - { - return false; - } - - if (!seriesTimer.RecordAnyTime - && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) - { - return true; - } - - if (seriesTimer.RecordNewOnly && timer.IsRepeat) - { - return true; - } - - if (!seriesTimer.RecordAnyChannel - && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer); - } - - private void HandleDuplicateShowIds(List timers) - { - // sort showings by HD channels first, then by startDate, record earliest showing possible - foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1)) - { - timer.Status = RecordingStatus.Cancelled; - _timerManager.Update(timer); - } - } - - private void SearchForDuplicateShowIds(IEnumerable timers) - { - var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList(); - - foreach (var group in groups) - { - if (string.IsNullOrWhiteSpace(group.Key)) - { - continue; - } - - var groupTimers = group.ToList(); - - if (groupTimers.Count < 2) - { - continue; - } - - // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856 - if (group.Key.EndsWith("0000", StringComparison.Ordinal)) - { - continue; - } - - HandleDuplicateShowIds(groupTimers); - } - } - - private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers) - { - var allTimers = GetTimersForSeries(seriesTimer).ToList(); - - var enabledTimersForSeries = new List(); - foreach (var timer in allTimers) - { - var existingTimer = _timerManager.GetTimer(timer.Id) - ?? (string.IsNullOrWhiteSpace(timer.ProgramId) - ? null - : _timerManager.GetTimerByProgramId(timer.ProgramId)); - - if (existingTimer is null) - { - if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) - { - timer.Status = RecordingStatus.Cancelled; - } - else - { - enabledTimersForSeries.Add(timer); - } - - _timerManager.Add(timer); - - TimerCreated?.Invoke(this, new GenericEventArgs(timer)); - } - - // 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 (_recordingsManager.GetActiveRecordingPath(timer.Id) is null - && _recordingsManager.GetActiveRecordingPath(existingTimer.Id) is null) - { - UpdateExistingTimerWithNewMetadata(existingTimer, timer); - - // Needed by ShouldCancelTimerForSeriesTimer - timer.IsManual = existingTimer.IsManual; - - if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) - { - existingTimer.Status = RecordingStatus.Cancelled; - } - else if (!existingTimer.IsManual) - { - existingTimer.Status = RecordingStatus.New; - } - - if (existingTimer.Status != RecordingStatus.Cancelled) - { - enabledTimersForSeries.Add(existingTimer); - } - - if (updateTimerSettings) - { - existingTimer.KeepUntil = seriesTimer.KeepUntil; - existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; - existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; - existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; - existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; - existingTimer.Priority = seriesTimer.Priority; - existingTimer.SeriesTimerId = seriesTimer.Id; - } - - existingTimer.SeriesTimerId = seriesTimer.Id; - _timerManager.Update(existingTimer); - } - } - - SearchForDuplicateShowIds(enabledTimersForSeries); - - if (deleteInvalidTimers) - { - var allTimerIds = allTimers - .Select(i => i.Id) - .ToList(); - - var deleteStatuses = new[] - { - RecordingStatus.New - }; - - var deletes = _timerManager.GetAll() - .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) - .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) - .Where(i => deleteStatuses.Contains(i.Status)) - .ToList(); - - foreach (var timer in deletes) - { - CancelTimerInternal(timer.Id, false, false); - } - } - } - - private IEnumerable GetTimersForSeries(SeriesTimerInfo seriesTimer) - { - ArgumentNullException.ThrowIfNull(seriesTimer); - - var query = new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ExternalSeriesId = seriesTimer.SeriesId, - DtoOptions = new DtoOptions(true) - { - EnableImages = false - }, - MinEndDate = DateTime.UtcNow - }; - - if (string.IsNullOrEmpty(seriesTimer.SeriesId)) - { - query.Name = seriesTimer.Name; - } - - if (!seriesTimer.RecordAnyChannel) - { - query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)]; - } - - var tempChannelCache = new Dictionary(); - - return _libraryManager.GetItemList(query).Cast().Select(i => CreateTimer(i, seriesTimer, tempChannelCache)); - } - - private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary tempChannelCache) - { - string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; - - if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.IsEmpty()) - { - if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) - { - channel = _libraryManager.GetItemList( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, - ItemIds = new[] { parent.ChannelId }, - DtoOptions = new DtoOptions() - }).FirstOrDefault() as LiveTvChannel; - - if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) - { - tempChannelCache[parent.ChannelId] = channel; - } - } - - if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel)) - { - channelId = channel.ExternalId; - } - } - - var timer = new TimerInfo - { - ChannelId = channelId, - Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture), - StartDate = parent.StartDate, - EndDate = parent.EndDate.Value, - ProgramId = parent.ExternalId, - PrePaddingSeconds = seriesTimer.PrePaddingSeconds, - PostPaddingSeconds = seriesTimer.PostPaddingSeconds, - IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired, - IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired, - KeepUntil = seriesTimer.KeepUntil, - Priority = seriesTimer.Priority, - Name = parent.Name, - Overview = parent.Overview, - SeriesId = parent.ExternalSeriesId, - SeriesTimerId = seriesTimer.Id, - ShowId = parent.ShowId - }; - - CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache); - - return timer; - } - - private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo) - { - var tempChannelCache = new Dictionary(); - CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache); - } - - private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary tempChannelCache) - { - string channelId = null; - - if (!programInfo.ChannelId.IsEmpty()) - { - if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) - { - channel = _libraryManager.GetItemList( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, - ItemIds = new[] { programInfo.ChannelId }, - DtoOptions = new DtoOptions() - }).FirstOrDefault() as LiveTvChannel; - - if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) - { - tempChannelCache[programInfo.ChannelId] = channel; - } - } - - if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel)) - { - channelId = channel.ExternalId; - } - } - - timerInfo.Name = programInfo.Name; - timerInfo.StartDate = programInfo.StartDate; - timerInfo.EndDate = programInfo.EndDate.Value; - - if (!string.IsNullOrWhiteSpace(channelId)) - { - timerInfo.ChannelId = channelId; - } - - timerInfo.SeasonNumber = programInfo.ParentIndexNumber; - timerInfo.EpisodeNumber = programInfo.IndexNumber; - timerInfo.IsMovie = programInfo.IsMovie; - timerInfo.ProductionYear = programInfo.ProductionYear; - timerInfo.EpisodeTitle = programInfo.EpisodeTitle; - timerInfo.OriginalAirDate = programInfo.PremiereDate; - timerInfo.IsProgramSeries = programInfo.IsSeries; - - timerInfo.IsSeries = programInfo.IsSeries; - - timerInfo.CommunityRating = programInfo.CommunityRating; - timerInfo.Overview = programInfo.Overview; - timerInfo.OfficialRating = programInfo.OfficialRating; - timerInfo.IsRepeat = programInfo.IsRepeat; - timerInfo.SeriesId = programInfo.ExternalSeriesId; - timerInfo.ProviderIds = programInfo.ProviderIds; - timerInfo.Tags = programInfo.Tags; - - var seriesProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var providerId in timerInfo.ProviderIds) - { - const string Search = "Series"; - if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase)) - { - seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value; - } - } - - timerInfo.SeriesProviderIds = seriesProviderIds; - } - - private bool IsProgramAlreadyInLibrary(TimerInfo program) - { - if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) - { - var seriesIds = _libraryManager.GetItemIds( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = program.Name - }).ToArray(); - - if (seriesIds.Length == 0) - { - return false; - } - - if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) - { - var result = _libraryManager.GetItemIds(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Episode }, - ParentIndexNumber = program.SeasonNumber.Value, - IndexNumber = program.EpisodeNumber.Value, - AncestorIds = seriesIds, - IsVirtualItem = false, - Limit = 1 - }); - - if (result.Count > 0) - { - return true; - } - } - } - - return false; - } - } -} diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index e247ecb44..73729c950 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -37,7 +37,7 @@ public static class LiveTvServiceCollectionExtensions 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 056bb6e6d..39f174cc2 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -141,7 +141,7 @@ public class GuideManager : IGuideManager CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken); } - var coreService = _liveTvManager.Services.OfType().FirstOrDefault(); + var coreService = _liveTvManager.Services.OfType().FirstOrDefault(); if (coreService is not null) { await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index f7b9604af..0af40a059 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -72,7 +72,7 @@ namespace Jellyfin.LiveTv _recordingsManager = recordingsManager; _services = services.ToArray(); - var defaultService = _services.OfType().First(); + var defaultService = _services.OfType().First(); defaultService.TimerCreated += OnEmbyTvTimerCreated; defaultService.TimerCancelled += OnEmbyTvTimerCancelled; } @@ -340,7 +340,7 @@ namespace Jellyfin.LiveTv // Set the total bitrate if not already supplied mediaSource.InferTotalBitrate(); - if (service is not EmbyTV.EmbyTV) + 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; @@ -769,7 +769,7 @@ namespace Jellyfin.LiveTv var channel = string.IsNullOrWhiteSpace(info.ChannelId) ? null - : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(EmbyTV.EmbyTV.ServiceName, info.ChannelId)); + : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(DefaultLiveTvService.ServiceName, info.ChannelId)); dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null @@ -1005,7 +1005,7 @@ namespace Jellyfin.LiveTv await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false); - if (service is not EmbyTV.EmbyTV) + if (service is not DefaultLiveTvService) { TimerCancelled?.Invoke(this, new GenericEventArgs(new TimerEventInfo(id))); } @@ -1314,7 +1314,7 @@ namespace Jellyfin.LiveTv _logger.LogInformation("New recording scheduled"); - if (service is not EmbyTV.EmbyTV) + if (service is not DefaultLiveTvService) { TimerCreated?.Invoke(this, new GenericEventArgs( new TimerEventInfo(newTimerId) diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs index 20f89ec8f..92605a1eb 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -11,7 +11,6 @@ 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; diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs index 0a71a4d46..b2b82332d 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs @@ -9,7 +9,6 @@ using System.Xml; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; -using Jellyfin.LiveTv.EmbyTV; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; -- cgit v1.2.3 From 3b341c06db66ae675b37102e6c5d4009def1b48d Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 22 Feb 2024 09:43:55 -0500 Subject: Move TimerInfo start time logic out of RecordingHelper --- src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs | 5 ----- src/Jellyfin.LiveTv/Timers/TimerManager.cs | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs index 1c8e2960b..2b7564045 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs @@ -7,11 +7,6 @@ namespace Jellyfin.LiveTv.Recordings { internal static class RecordingHelper { - public static DateTime GetStartTime(TimerInfo timer) - { - return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds); - } - public static string GetRecordingName(TimerInfo info) { var name = info.Name; diff --git a/src/Jellyfin.LiveTv/Timers/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs index 2e5003a53..da5deea36 100644 --- a/src/Jellyfin.LiveTv/Timers/TimerManager.cs +++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs @@ -95,7 +95,7 @@ namespace Jellyfin.LiveTv.Timers return; } - var startDate = RecordingHelper.GetStartTime(item); + var startDate = item.StartDate.AddSeconds(-item.PrePaddingSeconds); var now = DateTime.UtcNow; if (startDate < now) -- 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 'src') diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index ed08cdc47..c0e46ba24 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 0af40a059..c19d8195c 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 c6874e4db..40ac5ce0f 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 From 0bc41c015f4ec907de75fe215589b7e30a819b54 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Mon, 26 Feb 2024 05:09:40 -0700 Subject: Store lyrics in the database as media streams (#9951) --- Emby.Naming/Common/NamingOptions.cs | 12 + Emby.Naming/ExternalFiles/ExternalPathParser.cs | 3 +- Emby.Server.Implementations/Dto/DtoService.cs | 13 +- .../Library/LibraryManager.cs | 13 + .../UserPermissionPolicy/UserPermissionHandler.cs | 25 +- Jellyfin.Api/Controllers/LyricsController.cs | 267 +++++++++++++ Jellyfin.Api/Controllers/SubtitleController.cs | 38 +- Jellyfin.Api/Controllers/UserLibraryController.cs | 45 +-- Jellyfin.Data/Entities/User.cs | 1 + Jellyfin.Data/Enums/PermissionKind.cs | 7 +- .../Library/LyricDownloadFailureLogger.cs | 101 +++++ .../Events/EventingServiceCollectionExtensions.cs | 2 + .../Users/UserManager.cs | 1 + .../Extensions/ApiServiceCollectionExtensions.cs | 2 +- MediaBrowser.Common/Api/Policies.cs | 5 + MediaBrowser.Controller/Entities/Audio/Audio.cs | 12 + MediaBrowser.Controller/Library/ILibraryManager.cs | 9 + MediaBrowser.Controller/Lyrics/ILyricManager.cs | 100 ++++- MediaBrowser.Controller/Lyrics/ILyricParser.cs | 4 +- MediaBrowser.Controller/Lyrics/ILyricProvider.cs | 34 ++ .../Lyrics/LyricDownloadFailureEventArgs.cs | 26 ++ MediaBrowser.Controller/Lyrics/LyricFile.cs | 28 -- MediaBrowser.Controller/Lyrics/LyricLine.cs | 28 -- MediaBrowser.Controller/Lyrics/LyricMetadata.cs | 52 --- MediaBrowser.Controller/Lyrics/LyricResponse.cs | 20 - MediaBrowser.Model/Configuration/LibraryOptions.cs | 5 + .../Configuration/MetadataPluginType.cs | 3 +- MediaBrowser.Model/Dlna/DlnaProfileType.cs | 3 +- MediaBrowser.Model/Entities/MediaStreamType.cs | 7 +- MediaBrowser.Model/Lyrics/LyricDto.cs | 19 + MediaBrowser.Model/Lyrics/LyricFile.cs | 28 ++ MediaBrowser.Model/Lyrics/LyricLine.cs | 28 ++ MediaBrowser.Model/Lyrics/LyricMetadata.cs | 57 +++ MediaBrowser.Model/Lyrics/LyricResponse.cs | 19 + MediaBrowser.Model/Lyrics/LyricSearchRequest.cs | 59 +++ MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs | 22 ++ MediaBrowser.Model/Lyrics/UploadLyricDto.cs | 16 + MediaBrowser.Model/Providers/LyricProviderInfo.cs | 17 + MediaBrowser.Model/Providers/RemoteLyricInfo.cs | 29 ++ MediaBrowser.Model/Users/UserPolicy.cs | 6 + .../Lyric/DefaultLyricProvider.cs | 69 ---- MediaBrowser.Providers/Lyric/ILyricProvider.cs | 36 -- MediaBrowser.Providers/Lyric/LrcLyricParser.cs | 15 +- MediaBrowser.Providers/Lyric/LyricManager.cs | 428 +++++++++++++++++++-- MediaBrowser.Providers/Lyric/TxtLyricParser.cs | 11 +- MediaBrowser.Providers/Manager/ProviderManager.cs | 18 +- .../MediaInfo/AudioFileProber.cs | 34 +- MediaBrowser.Providers/MediaInfo/LyricResolver.cs | 39 ++ .../MediaInfo/MediaInfoResolver.cs | 97 ++++- MediaBrowser.Providers/MediaInfo/ProbeProvider.cs | 50 ++- .../Subtitles/SubtitleManager.cs | 2 +- src/Jellyfin.Extensions/StringExtensions.cs | 10 + .../Manager/ProviderManagerTests.cs | 4 +- 53 files changed, 1599 insertions(+), 380 deletions(-) create mode 100644 Jellyfin.Api/Controllers/LyricsController.cs create mode 100644 Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs create mode 100644 MediaBrowser.Controller/Lyrics/ILyricProvider.cs create mode 100644 MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs delete mode 100644 MediaBrowser.Controller/Lyrics/LyricFile.cs delete mode 100644 MediaBrowser.Controller/Lyrics/LyricLine.cs delete mode 100644 MediaBrowser.Controller/Lyrics/LyricMetadata.cs delete mode 100644 MediaBrowser.Controller/Lyrics/LyricResponse.cs create mode 100644 MediaBrowser.Model/Lyrics/LyricDto.cs create mode 100644 MediaBrowser.Model/Lyrics/LyricFile.cs create mode 100644 MediaBrowser.Model/Lyrics/LyricLine.cs create mode 100644 MediaBrowser.Model/Lyrics/LyricMetadata.cs create mode 100644 MediaBrowser.Model/Lyrics/LyricResponse.cs create mode 100644 MediaBrowser.Model/Lyrics/LyricSearchRequest.cs create mode 100644 MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs create mode 100644 MediaBrowser.Model/Lyrics/UploadLyricDto.cs create mode 100644 MediaBrowser.Model/Providers/LyricProviderInfo.cs create mode 100644 MediaBrowser.Model/Providers/RemoteLyricInfo.cs delete mode 100644 MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs delete mode 100644 MediaBrowser.Providers/Lyric/ILyricProvider.cs create mode 100644 MediaBrowser.Providers/MediaInfo/LyricResolver.cs (limited to 'src') diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index b63c8f10e..4bd226d95 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -173,6 +173,13 @@ namespace Emby.Naming.Common ".vtt", }; + LyricFileExtensions = new[] + { + ".lrc", + ".elrc", + ".txt" + }; + AlbumStackingPrefixes = new[] { "cd", @@ -791,6 +798,11 @@ namespace Emby.Naming.Common /// public string[] SubtitleFileExtensions { get; set; } + /// + /// Gets the list of lyric file extensions. + /// + public string[] LyricFileExtensions { get; } + /// /// Gets or sets list of episode regular expressions. /// diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 4080ba10d..9d54533c2 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -45,7 +45,8 @@ namespace Emby.Naming.ExternalFiles var extension = Path.GetExtension(path.AsSpan()); if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) - && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) + && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + && !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) { return null; } diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index d372277e0..7812687ea 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -18,7 +18,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; @@ -53,7 +52,6 @@ namespace Emby.Server.Implementations.Dto private readonly IMediaSourceManager _mediaSourceManager; private readonly Lazy _livetvManagerFactory; - private readonly ILyricManager _lyricManager; private readonly ITrickplayManager _trickplayManager; public DtoService( @@ -67,7 +65,6 @@ namespace Emby.Server.Implementations.Dto IApplicationHost appHost, IMediaSourceManager mediaSourceManager, Lazy livetvManagerFactory, - ILyricManager lyricManager, ITrickplayManager trickplayManager) { _logger = logger; @@ -80,7 +77,6 @@ namespace Emby.Server.Implementations.Dto _appHost = appHost; _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; - _lyricManager = lyricManager; _trickplayManager = trickplayManager; } @@ -152,10 +148,6 @@ namespace Emby.Server.Implementations.Dto { LivetvManager.AddInfoToProgramDto(new[] { (item, dto) }, options.Fields, user).GetAwaiter().GetResult(); } - else if (item is Audio) - { - dto.HasLyrics = _lyricManager.HasLyricFile(item); - } if (item is IItemByName itemByName && options.ContainsField(ItemFields.ItemCounts)) @@ -275,6 +267,11 @@ namespace Emby.Server.Implementations.Dto LivetvManager.AddInfoToRecordingDto(item, dto, activeRecording, user); } + if (item is Audio audio) + { + dto.HasLyrics = audio.GetMediaStreams().Any(s => s.Type == MediaStreamType.Lyric); + } + return dto; } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 7998ce34a..13a381060 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1232,6 +1232,19 @@ namespace Emby.Server.Implementations.Library return item; } + /// + public T GetItemById(Guid id) + where T : BaseItem + { + var item = GetItemById(id); + if (item is T typedItem) + { + return typedItem; + } + + return null; + } + public List GetItemList(InternalItemsQuery query, bool allowExternalContent) { if (query.Recursive && !query.ParentId.IsEmpty()) diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs index e72bec46f..764c0a435 100644 --- a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -25,15 +26,27 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy /// protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement) { - var user = _userManager.GetUserById(context.User.GetUserId()); - if (user is null) + // Api keys have global permissions, so just succeed the requirement. + if (context.User.GetIsApiKey()) { - throw new ResourceNotFoundException(); + context.Succeed(requirement); } - - if (user.HasPermission(requirement.RequiredPermission)) + else { - context.Succeed(requirement); + var userId = context.User.GetUserId(); + if (!userId.IsEmpty()) + { + var user = _userManager.GetUserById(context.User.GetUserId()); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + if (user.HasPermission(requirement.RequiredPermission)) + { + context.Succeed(requirement); + } + } } return Task.CompletedTask; diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs new file mode 100644 index 000000000..4fccf2cb4 --- /dev/null +++ b/Jellyfin.Api/Controllers/LyricsController.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Attributes; +using Jellyfin.Api.Extensions; +using Jellyfin.Extensions; +using MediaBrowser.Common.Api; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Lyrics; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers; + +/// +/// Lyrics controller. +/// +[Route("")] +public class LyricsController : BaseJellyfinApiController +{ + private readonly ILibraryManager _libraryManager; + private readonly ILyricManager _lyricManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly IUserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public LyricsController( + ILibraryManager libraryManager, + ILyricManager lyricManager, + IProviderManager providerManager, + IFileSystem fileSystem, + IUserManager userManager) + { + _libraryManager = libraryManager; + _lyricManager = lyricManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _userManager = userManager; + } + + /// + /// Gets an item's lyrics. + /// + /// Item id. + /// Lyrics returned. + /// Something went wrong. No Lyrics will be returned. + /// An containing the item's lyrics. + [HttpGet("Audio/{itemId}/Lyrics")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetLyrics([FromRoute, Required] Guid itemId) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + if (!isApiKey && userId.IsEmpty()) + { + return BadRequest(); + } + + var audio = _libraryManager.GetItemById /// The raw lyrics content. /// The parsed lyrics or null if invalid. - LyricResponse? ParseLyrics(LyricFile lyrics); + LyricDto? ParseLyrics(LyricFile lyrics); } diff --git a/MediaBrowser.Controller/Lyrics/ILyricProvider.cs b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs new file mode 100644 index 000000000..0831a4c4e --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Lyrics; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Controller.Lyrics; + +/// +/// Interface ILyricsProvider. +/// +public interface ILyricProvider +{ + /// + /// Gets the provider name. + /// + string Name { get; } + + /// + /// Search for lyrics. + /// + /// The search request. + /// The cancellation token. + /// The list of remote lyrics. + Task> SearchAsync(LyricSearchRequest request, CancellationToken cancellationToken); + + /// + /// Get the lyrics. + /// + /// The remote lyric id. + /// The cancellation token. + /// The lyric response. + Task GetLyricsAsync(string id, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs b/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs new file mode 100644 index 000000000..1b1f36020 --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs @@ -0,0 +1,26 @@ +using System; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Lyrics +{ + /// + /// An event that occurs when subtitle downloading fails. + /// + public class LyricDownloadFailureEventArgs : EventArgs + { + /// + /// Gets or sets the item. + /// + public required BaseItem Item { get; set; } + + /// + /// Gets or sets the provider. + /// + public required string Provider { get; set; } + + /// + /// Gets or sets the exception. + /// + public required Exception Exception { get; set; } + } +} diff --git a/MediaBrowser.Controller/Lyrics/LyricFile.cs b/MediaBrowser.Controller/Lyrics/LyricFile.cs deleted file mode 100644 index ede89403c..000000000 --- a/MediaBrowser.Controller/Lyrics/LyricFile.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace MediaBrowser.Providers.Lyric; - -/// -/// The information for a raw lyrics file before parsing. -/// -public class LyricFile -{ - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The content, must not be empty. - public LyricFile(string name, string content) - { - Name = name; - Content = content; - } - - /// - /// Gets or sets the name of the lyrics file. This must include the file extension. - /// - public string Name { get; set; } - - /// - /// Gets or sets the contents of the file. - /// - public string Content { get; set; } -} diff --git a/MediaBrowser.Controller/Lyrics/LyricLine.cs b/MediaBrowser.Controller/Lyrics/LyricLine.cs deleted file mode 100644 index c406f92fc..000000000 --- a/MediaBrowser.Controller/Lyrics/LyricLine.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace MediaBrowser.Controller.Lyrics; - -/// -/// Lyric model. -/// -public class LyricLine -{ - /// - /// Initializes a new instance of the class. - /// - /// The lyric text. - /// The lyric start time in ticks. - public LyricLine(string text, long? start = null) - { - Text = text; - Start = start; - } - - /// - /// Gets the text of this lyric line. - /// - public string Text { get; } - - /// - /// Gets the start time in ticks. - /// - public long? Start { get; } -} diff --git a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs b/MediaBrowser.Controller/Lyrics/LyricMetadata.cs deleted file mode 100644 index c4f033489..000000000 --- a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace MediaBrowser.Controller.Lyrics; - -/// -/// LyricMetadata model. -/// -public class LyricMetadata -{ - /// - /// Gets or sets the song artist. - /// - public string? Artist { get; set; } - - /// - /// Gets or sets the album this song is on. - /// - public string? Album { get; set; } - - /// - /// Gets or sets the title of the song. - /// - public string? Title { get; set; } - - /// - /// Gets or sets the author of the lyric data. - /// - public string? Author { get; set; } - - /// - /// Gets or sets the length of the song in ticks. - /// - public long? Length { get; set; } - - /// - /// Gets or sets who the LRC file was created by. - /// - public string? By { get; set; } - - /// - /// Gets or sets the lyric offset compared to audio in ticks. - /// - public long? Offset { get; set; } - - /// - /// Gets or sets the software used to create the LRC file. - /// - public string? Creator { get; set; } - - /// - /// Gets or sets the version of the creator used. - /// - public string? Version { get; set; } -} diff --git a/MediaBrowser.Controller/Lyrics/LyricResponse.cs b/MediaBrowser.Controller/Lyrics/LyricResponse.cs deleted file mode 100644 index 0d52b5ec5..000000000 --- a/MediaBrowser.Controller/Lyrics/LyricResponse.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace MediaBrowser.Controller.Lyrics; - -/// -/// LyricResponse model. -/// -public class LyricResponse -{ - /// - /// Gets or sets Metadata for the lyrics. - /// - public LyricMetadata Metadata { get; set; } = new(); - - /// - /// Gets or sets a collection of individual lyric lines. - /// - public IReadOnlyList Lyrics { get; set; } = Array.Empty(); -} diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 1c071067d..42148a276 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using System.ComponentModel; namespace MediaBrowser.Model.Configuration { @@ -20,6 +21,7 @@ namespace MediaBrowser.Model.Configuration AutomaticallyAddToCollection = false; EnablePhotos = true; SaveSubtitlesWithMedia = true; + SaveLyricsWithMedia = true; PathInfos = Array.Empty(); EnableAutomaticSeriesGrouping = true; SeasonZeroDisplayName = "Specials"; @@ -92,6 +94,9 @@ namespace MediaBrowser.Model.Configuration public bool SaveSubtitlesWithMedia { get; set; } + [DefaultValue(true)] + public bool SaveLyricsWithMedia { get; set; } + public bool AutomaticallyAddToCollection { get; set; } public EmbeddedSubtitleOptions AllowEmbeddedSubtitles { get; set; } diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs index 4c5e95266..ef303726d 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -13,6 +13,7 @@ namespace MediaBrowser.Model.Configuration LocalMetadataProvider, MetadataFetcher, MetadataSaver, - SubtitleFetcher + SubtitleFetcher, + LyricFetcher } } diff --git a/MediaBrowser.Model/Dlna/DlnaProfileType.cs b/MediaBrowser.Model/Dlna/DlnaProfileType.cs index c1a663bf1..1bb885c44 100644 --- a/MediaBrowser.Model/Dlna/DlnaProfileType.cs +++ b/MediaBrowser.Model/Dlna/DlnaProfileType.cs @@ -7,6 +7,7 @@ namespace MediaBrowser.Model.Dlna Audio = 0, Video = 1, Photo = 2, - Subtitle = 3 + Subtitle = 3, + Lyric = 4 } } diff --git a/MediaBrowser.Model/Entities/MediaStreamType.cs b/MediaBrowser.Model/Entities/MediaStreamType.cs index 83751a6a7..0964bb769 100644 --- a/MediaBrowser.Model/Entities/MediaStreamType.cs +++ b/MediaBrowser.Model/Entities/MediaStreamType.cs @@ -28,6 +28,11 @@ namespace MediaBrowser.Model.Entities /// /// The data. /// - Data + Data, + + /// + /// The lyric. + /// + Lyric } } diff --git a/MediaBrowser.Model/Lyrics/LyricDto.cs b/MediaBrowser.Model/Lyrics/LyricDto.cs new file mode 100644 index 000000000..7a9bffc99 --- /dev/null +++ b/MediaBrowser.Model/Lyrics/LyricDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace MediaBrowser.Model.Lyrics; + +/// +/// LyricResponse model. +/// +public class LyricDto +{ + /// + /// Gets or sets Metadata for the lyrics. + /// + public LyricMetadata Metadata { get; set; } = new(); + + /// + /// Gets or sets a collection of individual lyric lines. + /// + public IReadOnlyList Lyrics { get; set; } = []; +} diff --git a/MediaBrowser.Model/Lyrics/LyricFile.cs b/MediaBrowser.Model/Lyrics/LyricFile.cs new file mode 100644 index 000000000..3912b037e --- /dev/null +++ b/MediaBrowser.Model/Lyrics/LyricFile.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.Lyrics; + +/// +/// The information for a raw lyrics file before parsing. +/// +public class LyricFile +{ + /// + /// Initializes a new instance of the class. + /// + /// The name. + /// The content, must not be empty. + public LyricFile(string name, string content) + { + Name = name; + Content = content; + } + + /// + /// Gets or sets the name of the lyrics file. This must include the file extension. + /// + public string Name { get; set; } + + /// + /// Gets or sets the contents of the file. + /// + public string Content { get; set; } +} diff --git a/MediaBrowser.Model/Lyrics/LyricLine.cs b/MediaBrowser.Model/Lyrics/LyricLine.cs new file mode 100644 index 000000000..64d1f64c2 --- /dev/null +++ b/MediaBrowser.Model/Lyrics/LyricLine.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.Lyrics; + +/// +/// Lyric model. +/// +public class LyricLine +{ + /// + /// Initializes a new instance of the class. + /// + /// The lyric text. + /// The lyric start time in ticks. + public LyricLine(string text, long? start = null) + { + Text = text; + Start = start; + } + + /// + /// Gets the text of this lyric line. + /// + public string Text { get; } + + /// + /// Gets the start time in ticks. + /// + public long? Start { get; } +} diff --git a/MediaBrowser.Model/Lyrics/LyricMetadata.cs b/MediaBrowser.Model/Lyrics/LyricMetadata.cs new file mode 100644 index 000000000..4f819d6c9 --- /dev/null +++ b/MediaBrowser.Model/Lyrics/LyricMetadata.cs @@ -0,0 +1,57 @@ +namespace MediaBrowser.Model.Lyrics; + +/// +/// LyricMetadata model. +/// +public class LyricMetadata +{ + /// + /// Gets or sets the song artist. + /// + public string? Artist { get; set; } + + /// + /// Gets or sets the album this song is on. + /// + public string? Album { get; set; } + + /// + /// Gets or sets the title of the song. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the author of the lyric data. + /// + public string? Author { get; set; } + + /// + /// Gets or sets the length of the song in ticks. + /// + public long? Length { get; set; } + + /// + /// Gets or sets who the LRC file was created by. + /// + public string? By { get; set; } + + /// + /// Gets or sets the lyric offset compared to audio in ticks. + /// + public long? Offset { get; set; } + + /// + /// Gets or sets the software used to create the LRC file. + /// + public string? Creator { get; set; } + + /// + /// Gets or sets the version of the creator used. + /// + public string? Version { get; set; } + + /// + /// Gets or sets a value indicating whether this lyric is synced. + /// + public bool? IsSynced { get; set; } +} diff --git a/MediaBrowser.Model/Lyrics/LyricResponse.cs b/MediaBrowser.Model/Lyrics/LyricResponse.cs new file mode 100644 index 000000000..b04adeb7b --- /dev/null +++ b/MediaBrowser.Model/Lyrics/LyricResponse.cs @@ -0,0 +1,19 @@ +using System.IO; + +namespace MediaBrowser.Model.Lyrics; + +/// +/// LyricResponse model. +/// +public class LyricResponse +{ + /// + /// Gets or sets the lyric stream. + /// + public required Stream Stream { get; set; } + + /// + /// Gets or sets the lyric format. + /// + public required string Format { get; set; } +} diff --git a/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs new file mode 100644 index 000000000..48c442a55 --- /dev/null +++ b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Model.Lyrics; + +/// +/// Lyric search request. +/// +public class LyricSearchRequest : IHasProviderIds +{ + /// + /// Gets or sets the media path. + /// + public string? MediaPath { get; set; } + + /// + /// Gets or sets the artist name. + /// + public IReadOnlyList? ArtistNames { get; set; } + + /// + /// Gets or sets the album name. + /// + public string? AlbumName { get; set; } + + /// + /// Gets or sets the song name. + /// + public string? SongName { get; set; } + + /// + /// Gets or sets the track duration in ticks. + /// + public long? Duration { get; set; } + + /// + public Dictionary ProviderIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets a value indicating whether to search all providers. + /// + public bool SearchAllProviders { get; set; } = true; + + /// + /// Gets or sets the list of disabled lyric fetcher names. + /// + public IReadOnlyList DisabledLyricFetchers { get; set; } = []; + + /// + /// Gets or sets the order of lyric fetchers. + /// + public IReadOnlyList LyricFetcherOrder { get; set; } = []; + + /// + /// Gets or sets a value indicating whether this request is automated. + /// + public bool IsAutomated { get; set; } +} diff --git a/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs b/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs new file mode 100644 index 000000000..dda56d198 --- /dev/null +++ b/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs @@ -0,0 +1,22 @@ +namespace MediaBrowser.Model.Lyrics; + +/// +/// The remote lyric info dto. +/// +public class RemoteLyricInfoDto +{ + /// + /// Gets or sets the id for the lyric. + /// + public required string Id { get; set; } + + /// + /// Gets the provider name. + /// + public required string ProviderName { get; init; } + + /// + /// Gets the lyrics. + /// + public required LyricDto Lyrics { get; init; } +} diff --git a/MediaBrowser.Model/Lyrics/UploadLyricDto.cs b/MediaBrowser.Model/Lyrics/UploadLyricDto.cs new file mode 100644 index 000000000..0ea8a4c63 --- /dev/null +++ b/MediaBrowser.Model/Lyrics/UploadLyricDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace MediaBrowser.Model.Lyrics; + +/// +/// Upload lyric dto. +/// +public class UploadLyricDto +{ + /// + /// Gets or sets the lyrics file. + /// + [Required] + public IFormFile Lyrics { get; set; } = null!; +} diff --git a/MediaBrowser.Model/Providers/LyricProviderInfo.cs b/MediaBrowser.Model/Providers/LyricProviderInfo.cs new file mode 100644 index 000000000..ea9c94185 --- /dev/null +++ b/MediaBrowser.Model/Providers/LyricProviderInfo.cs @@ -0,0 +1,17 @@ +namespace MediaBrowser.Model.Providers; + +/// +/// Lyric provider info. +/// +public class LyricProviderInfo +{ + /// + /// Gets the provider name. + /// + public required string Name { get; init; } + + /// + /// Gets the provider id. + /// + public required string Id { get; init; } +} diff --git a/MediaBrowser.Model/Providers/RemoteLyricInfo.cs b/MediaBrowser.Model/Providers/RemoteLyricInfo.cs new file mode 100644 index 000000000..9fb340a58 --- /dev/null +++ b/MediaBrowser.Model/Providers/RemoteLyricInfo.cs @@ -0,0 +1,29 @@ +using MediaBrowser.Model.Lyrics; + +namespace MediaBrowser.Model.Providers; + +/// +/// The remote lyric info. +/// +public class RemoteLyricInfo +{ + /// + /// Gets or sets the id for the lyric. + /// + public required string Id { get; set; } + + /// + /// Gets the provider name. + /// + public required string ProviderName { get; init; } + + /// + /// Gets the lyric metadata. + /// + public required LyricMetadata Metadata { get; init; } + + /// + /// Gets the lyrics. + /// + public required LyricResponse Lyrics { get; init; } +} diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 219ed5d5f..951e05763 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -92,6 +92,12 @@ namespace MediaBrowser.Model.Users [DefaultValue(false)] public bool EnableSubtitleManagement { get; set; } + /// + /// Gets or sets a value indicating whether this user can manage lyrics. + /// + [DefaultValue(false)] + public bool EnableLyricManagement { get; set; } + /// /// Gets or sets a value indicating whether this instance is disabled. /// diff --git a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs b/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs deleted file mode 100644 index ab09f278a..000000000 --- a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using Jellyfin.Extensions; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Resolvers; - -namespace MediaBrowser.Providers.Lyric; - -/// -public class DefaultLyricProvider : ILyricProvider -{ - private static readonly string[] _lyricExtensions = { ".lrc", ".elrc", ".txt" }; - - /// - public string Name => "DefaultLyricProvider"; - - /// - public ResolverPriority Priority => ResolverPriority.First; - - /// - public bool HasLyrics(BaseItem item) - { - var path = GetLyricsPath(item); - return path is not null; - } - - /// - public async Task GetLyrics(BaseItem item) - { - var path = GetLyricsPath(item); - if (path is not null) - { - var content = await File.ReadAllTextAsync(path).ConfigureAwait(false); - if (!string.IsNullOrEmpty(content)) - { - return new LyricFile(path, content); - } - } - - return null; - } - - private string? GetLyricsPath(BaseItem item) - { - // Ensure the path to the item is not null - string? itemDirectoryPath = Path.GetDirectoryName(item.Path); - if (itemDirectoryPath is null) - { - return null; - } - - // Ensure the directory path exists - if (!Directory.Exists(itemDirectoryPath)) - { - return null; - } - - foreach (var lyricFilePath in Directory.GetFiles(itemDirectoryPath, $"{Path.GetFileNameWithoutExtension(item.Path)}.*")) - { - if (_lyricExtensions.Contains(Path.GetExtension(lyricFilePath.AsSpan()), StringComparison.OrdinalIgnoreCase)) - { - return lyricFilePath; - } - } - - return null; - } -} diff --git a/MediaBrowser.Providers/Lyric/ILyricProvider.cs b/MediaBrowser.Providers/Lyric/ILyricProvider.cs deleted file mode 100644 index 27ceba72b..000000000 --- a/MediaBrowser.Providers/Lyric/ILyricProvider.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Resolvers; - -namespace MediaBrowser.Providers.Lyric; - -/// -/// Interface ILyricsProvider. -/// -public interface ILyricProvider -{ - /// - /// Gets a value indicating the provider name. - /// - string Name { get; } - - /// - /// Gets the priority. - /// - /// The priority. - ResolverPriority Priority { get; } - - /// - /// Checks if an item has lyrics available. - /// - /// The media item. - /// Whether lyrics where found or not. - bool HasLyrics(BaseItem item); - - /// - /// Gets the lyrics. - /// - /// The media item. - /// A task representing found lyrics. - Task GetLyrics(BaseItem item); -} diff --git a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs index a10ff198b..67b26e457 100644 --- a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs +++ b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs @@ -8,6 +8,7 @@ using LrcParser.Model; using LrcParser.Parser; using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Lyrics; namespace MediaBrowser.Providers.Lyric; @@ -18,8 +19,8 @@ public class LrcLyricParser : ILyricParser { private readonly LyricParser _lrcLyricParser; - private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc" }; - private static readonly string[] _acceptedTimeFormats = { "HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss" }; + private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc"]; + private static readonly string[] _acceptedTimeFormats = ["HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss"]; /// /// Initializes a new instance of the class. @@ -39,7 +40,7 @@ public class LrcLyricParser : ILyricParser public ResolverPriority Priority => ResolverPriority.Fourth; /// - public LyricResponse? ParseLyrics(LyricFile lyrics) + public LyricDto? ParseLyrics(LyricFile lyrics) { if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase)) { @@ -95,7 +96,7 @@ public class LrcLyricParser : ILyricParser return null; } - List lyricList = new(); + List lyricList = []; for (int i = 0; i < sortedLyricData.Count; i++) { @@ -106,7 +107,7 @@ public class LrcLyricParser : ILyricParser } long ticks = TimeSpan.FromMilliseconds(timeData.Value).Ticks; - lyricList.Add(new LyricLine(sortedLyricData[i].Text, ticks)); + lyricList.Add(new LyricLine(sortedLyricData[i].Text.Trim(), ticks)); } if (fileMetaData.Count != 0) @@ -114,10 +115,10 @@ public class LrcLyricParser : ILyricParser // Map metaData values from LRC file to LyricMetadata properties LyricMetadata lyricMetadata = MapMetadataValues(fileMetaData); - return new LyricResponse { Metadata = lyricMetadata, Lyrics = lyricList }; + return new LyricDto { Metadata = lyricMetadata, Lyrics = lyricList }; } - return new LyricResponse { Lyrics = lyricList }; + return new LyricDto { Lyrics = lyricList }; } /// diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index 6da811927..60734b89a 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -1,8 +1,25 @@ +using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; +using System.Text; +using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Lyrics; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Lyric; @@ -11,37 +28,246 @@ namespace MediaBrowser.Providers.Lyric; /// public class LyricManager : ILyricManager { + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + private readonly ILibraryMonitor _libraryMonitor; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly ILyricProvider[] _lyricProviders; private readonly ILyricParser[] _lyricParsers; /// /// Initializes a new instance of the class. /// - /// All found lyricProviders. - /// All found lyricParsers. - public LyricManager(IEnumerable lyricProviders, IEnumerable lyricParsers) + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// The list of . + /// The list of . + public LyricManager( + ILogger logger, + IFileSystem fileSystem, + ILibraryMonitor libraryMonitor, + IMediaSourceManager mediaSourceManager, + IEnumerable lyricProviders, + IEnumerable lyricParsers) + { + _logger = logger; + _fileSystem = fileSystem; + _libraryMonitor = libraryMonitor; + _mediaSourceManager = mediaSourceManager; + _lyricProviders = lyricProviders + .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) + .ToArray(); + _lyricParsers = lyricParsers + .OrderBy(l => l.Priority) + .ToArray(); + } + + /// + public event EventHandler? LyricDownloadFailure; + + /// + public Task> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(audio); + + var request = new LyricSearchRequest + { + MediaPath = audio.Path, + SongName = audio.Name, + AlbumName = audio.Album, + ArtistNames = audio.GetAllArtists().ToList(), + Duration = audio.RunTimeTicks, + IsAutomated = isAutomated + }; + + return SearchLyricsAsync(request, cancellationToken); + } + + /// + public async Task> SearchLyricsAsync(LyricSearchRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var providers = _lyricProviders + .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase)) + .OrderBy(i => + { + var index = request.LyricFetcherOrder.IndexOf(i.Name); + return index == -1 ? int.MaxValue : index; + }) + .ToArray(); + + // If not searching all, search one at a time until something is found + if (!request.SearchAllProviders) + { + foreach (var provider in providers) + { + var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false); + if (providerResult.Count > 0) + { + return providerResult; + } + } + + return []; + } + + var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false)); + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + return results.SelectMany(i => i).ToArray(); + } + + /// + public Task DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(audio); + ArgumentException.ThrowIfNullOrWhiteSpace(lyricId); + + var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio); + + return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken); + } + + /// + public async Task DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(audio); + ArgumentNullException.ThrowIfNull(libraryOptions); + ArgumentException.ThrowIfNullOrWhiteSpace(lyricId); + + var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString()); + if (provider is null) + { + return null; + } + + try + { + var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false); + if (response is null) + { + _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId); + return null; + } + + var parsedLyrics = await InternalParseRemoteLyricsAsync(response, cancellationToken).ConfigureAwait(false); + if (parsedLyrics is null) + { + return null; + } + + await TrySaveLyric(audio, libraryOptions, response).ConfigureAwait(false); + return parsedLyrics; + } + catch (RateLimitExceededException) + { + throw; + } + catch (Exception ex) + { + LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs + { + Item = audio, + Exception = ex, + Provider = provider.Name + }); + + throw; + } + } + + /// + public async Task UploadLyricAsync(Audio audio, LyricResponse lyricResponse) { - _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray(); - _lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray(); + ArgumentNullException.ThrowIfNull(audio); + ArgumentNullException.ThrowIfNull(lyricResponse); + var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio); + + var parsed = await InternalParseRemoteLyricsAsync(lyricResponse, CancellationToken.None).ConfigureAwait(false); + if (parsed is null) + { + return null; + } + + await TrySaveLyric(audio, libraryOptions, lyricResponse).ConfigureAwait(false); + return parsed; + } + + /// + public async Task GetRemoteLyricsAsync(string id, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(id); + + var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false); + if (lyricResponse is null) + { + return null; + } + + return await InternalParseRemoteLyricsAsync(lyricResponse, cancellationToken).ConfigureAwait(false); } /// - public async Task GetLyrics(BaseItem item) + public Task DeleteLyricsAsync(Audio audio) { - foreach (ILyricProvider provider in _lyricProviders) + ArgumentNullException.ThrowIfNull(audio); + var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery + { + ItemId = audio.Id, + Type = MediaStreamType.Lyric + }); + + foreach (var stream in streams) { - var lyrics = await provider.GetLyrics(item).ConfigureAwait(false); - if (lyrics is null) + var path = stream.Path; + _libraryMonitor.ReportFileSystemChangeBeginning(path); + + try { - continue; + _fileSystem.DeleteFile(path); } + finally + { + _libraryMonitor.ReportFileSystemChangeComplete(path, false); + } + } + + return audio.RefreshMetadata(CancellationToken.None); + } + + /// + public IReadOnlyList GetSupportedProviders(BaseItem item) + { + if (item is not Audio) + { + return []; + } + + return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList(); + } - foreach (ILyricParser parser in _lyricParsers) + /// + public async Task GetLyricsAsync(Audio audio, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(audio); + + var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric); + foreach (var lyricStream in lyricStreams) + { + var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + + var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents); + foreach (var parser in _lyricParsers) { - var result = parser.ParseLyrics(lyrics); - if (result is not null) + var parsedLyrics = parser.ParseLyrics(lyricFile); + if (parsedLyrics is not null) { - return result; + return parsedLyrics; } } } @@ -49,22 +275,180 @@ public class LyricManager : ILyricManager return null; } - /// - public bool HasLyricFile(BaseItem item) + private ILyricProvider? GetProvider(string providerId) + { + var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringComparison.Ordinal)); + if (provider is null) + { + _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty)); + } + + return provider; + } + + private string GetProviderId(string name) + => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); + + private async Task InternalParseRemoteLyricsAsync(LyricResponse lyricResponse, CancellationToken cancellationToken) + { + lyricResponse.Stream.Seek(0, SeekOrigin.Begin); + using var streamReader = new StreamReader(lyricResponse.Stream, leaveOpen: true); + var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var lyricFile = new LyricFile($"lyric.{lyricResponse.Format}", lyrics); + foreach (var parser in _lyricParsers) + { + var parsedLyrics = parser.ParseLyrics(lyricFile); + if (parsedLyrics is not null) + { + return parsedLyrics; + } + } + + return null; + } + + private async Task InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + var parts = id.Split('_', 2); + var provider = GetProvider(parts[0]); + if (provider is null) + { + return null; + } + + id = parts[^1]; + + return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false); + } + + private async Task> InternalSearchProviderAsync( + ILyricProvider provider, + LyricSearchRequest request, + CancellationToken cancellationToken) + { + try + { + var providerId = GetProviderId(provider.Name); + var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false); + var parsedResults = new List(); + foreach (var result in searchResults) + { + var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics, cancellationToken).ConfigureAwait(false); + if (parsedLyrics is null) + { + continue; + } + + parsedLyrics.Metadata = result.Metadata; + parsedResults.Add(new RemoteLyricInfoDto + { + Id = $"{providerId}_{result.Id}", + ProviderName = result.ProviderName, + Lyrics = parsedLyrics + }); + } + + return parsedResults; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name); + return []; + } + } + + private async Task TrySaveLyric( + Audio audio, + LibraryOptions libraryOptions, + LyricResponse lyricResponse) { - foreach (ILyricProvider provider in _lyricProviders) + var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia; + + var memoryStream = new MemoryStream(); + await using (memoryStream.ConfigureAwait(false)) { - if (item is null) + var stream = lyricResponse.Stream; + + await using (stream.ConfigureAwait(false)) { - continue; + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Seek(0, SeekOrigin.Begin); } - if (provider.HasLyrics(item)) + var savePaths = new List(); + var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + lyricResponse.Format.ReplaceLineEndings(string.Empty).ToLowerInvariant(); + + if (saveInMediaFolder) { - return true; + var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName)); + // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path."); + if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal)) + { + savePaths.Add(mediaFolderPath); + } + } + + var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName)); + + // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path."); + if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal)) + { + savePaths.Add(internalPath); + } + + if (savePaths.Count > 0) + { + await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); + } + else + { + _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid."); } } + } - return false; + private async Task TrySaveToFiles(Stream stream, List savePaths) + { + List? exs = null; + + foreach (var savePath in savePaths) + { + _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty)); + + _libraryMonitor.ReportFileSystemChangeBeginning(savePath); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path can't be a root directory.")); + + var fileOptions = AsyncFile.WriteOptions; + fileOptions.Mode = FileMode.Create; + fileOptions.PreallocationSize = stream.Length; + var fs = new FileStream(savePath, fileOptions); + await using (fs.ConfigureAwait(false)) + { + await stream.CopyToAsync(fs).ConfigureAwait(false); + } + + return; + } + catch (Exception ex) + { + (exs ??= []).Add(ex); + } + finally + { + _libraryMonitor.ReportFileSystemChangeComplete(savePath, false); + } + + stream.Position = 0; + } + + if (exs is not null) + { + throw new AggregateException(exs); + } } } diff --git a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs index 706f13dbc..a8188da28 100644 --- a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs +++ b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs @@ -3,6 +3,7 @@ using System.IO; using Jellyfin.Extensions; using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Lyrics; namespace MediaBrowser.Providers.Lyric; @@ -11,8 +12,8 @@ namespace MediaBrowser.Providers.Lyric; /// public class TxtLyricParser : ILyricParser { - private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc", ".txt" }; - private static readonly string[] _lineBreakCharacters = { "\r\n", "\r", "\n" }; + private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc", ".txt"]; + private static readonly string[] _lineBreakCharacters = ["\r\n", "\r", "\n"]; /// public string Name => "TxtLyricProvider"; @@ -24,7 +25,7 @@ public class TxtLyricParser : ILyricParser public ResolverPriority Priority => ResolverPriority.Fifth; /// - public LyricResponse? ParseLyrics(LyricFile lyrics) + public LyricDto? ParseLyrics(LyricFile lyrics) { if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase)) { @@ -36,9 +37,9 @@ public class TxtLyricParser : ILyricParser for (int lyricLineIndex = 0; lyricLineIndex < lyricTextLines.Length; lyricLineIndex++) { - lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex]); + lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex].Trim()); } - return new LyricResponse { Lyrics = lyricList }; + return new LyricDto { Lyrics = lyricList }; } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 2e9547bf3..81a299015 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -21,6 +21,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Configuration; @@ -52,6 +53,7 @@ namespace MediaBrowser.Providers.Manager private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly ISubtitleManager _subtitleManager; + private readonly ILyricManager _lyricManager; private readonly IServerConfigurationManager _configurationManager; private readonly IBaseItemManager _baseItemManager; private readonly ConcurrentDictionary _activeRefreshes = new(); @@ -78,6 +80,7 @@ namespace MediaBrowser.Providers.Manager /// The server application paths. /// The library manager. /// The BaseItem manager. + /// The lyric manager. public ProviderManager( IHttpClientFactory httpClientFactory, ISubtitleManager subtitleManager, @@ -87,7 +90,8 @@ namespace MediaBrowser.Providers.Manager IFileSystem fileSystem, IServerApplicationPaths appPaths, ILibraryManager libraryManager, - IBaseItemManager baseItemManager) + IBaseItemManager baseItemManager, + ILyricManager lyricManager) { _logger = logger; _httpClientFactory = httpClientFactory; @@ -98,6 +102,7 @@ namespace MediaBrowser.Providers.Manager _libraryManager = libraryManager; _subtitleManager = subtitleManager; _baseItemManager = baseItemManager; + _lyricManager = lyricManager; } /// @@ -503,15 +508,22 @@ namespace MediaBrowser.Providers.Manager AddMetadataPlugins(pluginList, dummy, libraryOptions, options); AddImagePlugins(pluginList, imageProviders); - var subtitleProviders = _subtitleManager.GetSupportedProviders(dummy); - // Subtitle fetchers + var subtitleProviders = _subtitleManager.GetSupportedProviders(dummy); pluginList.AddRange(subtitleProviders.Select(i => new MetadataPlugin { Name = i.Name, Type = MetadataPluginType.SubtitleFetcher })); + // Lyric fetchers + var lyricProviders = _lyricManager.GetSupportedProviders(dummy); + pluginList.AddRange(lyricProviders.Select(i => new MetadataPlugin + { + Name = i.Name, + Type = MetadataPluginType.LyricFetcher + })); + summary.Plugins = pluginList.ToArray(); var supportedImageTypes = imageProviders.OfType() diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index f718325df..fb86e254f 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -35,6 +35,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly IItemRepository _itemRepo; private readonly ILibraryManager _libraryManager; private readonly IMediaSourceManager _mediaSourceManager; + private readonly LyricResolver _lyricResolver; /// /// Initializes a new instance of the class. @@ -44,18 +45,21 @@ namespace MediaBrowser.Providers.MediaInfo /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public AudioFileProber( ILogger logger, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IItemRepository itemRepo, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + LyricResolver lyricResolver) { _logger = logger; _mediaEncoder = mediaEncoder; _itemRepo = itemRepo; _libraryManager = libraryManager; _mediaSourceManager = mediaSourceManager; + _lyricResolver = lyricResolver; } [GeneratedRegex(@"I:\s+(.*?)\s+LUFS")] @@ -103,7 +107,7 @@ namespace MediaBrowser.Providers.MediaInfo cancellationToken.ThrowIfCancellationRequested(); - Fetch(item, result, cancellationToken); + Fetch(item, result, options, cancellationToken); } var libraryOptions = _libraryManager.GetLibraryOptions(item); @@ -205,8 +209,13 @@ namespace MediaBrowser.Providers.MediaInfo /// /// The . /// The . + /// The . /// The . - protected void Fetch(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, CancellationToken cancellationToken) + protected void Fetch( + Audio audio, + Model.MediaInfo.MediaInfo mediaInfo, + MetadataRefreshOptions options, + CancellationToken cancellationToken) { audio.Container = mediaInfo.Container; audio.TotalBitrate = mediaInfo.Bitrate; @@ -219,7 +228,12 @@ namespace MediaBrowser.Providers.MediaInfo FetchDataFromTags(audio); } - _itemRepo.SaveMediaStreams(audio.Id, mediaInfo.MediaStreams, cancellationToken); + var mediaStreams = new List(mediaInfo.MediaStreams); + AddExternalLyrics(audio, mediaStreams, options); + + audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric); + + _itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken); } /// @@ -333,5 +347,17 @@ namespace MediaBrowser.Providers.MediaInfo audio.SetProviderId(MetadataProvider.MusicBrainzTrack, tags.MusicBrainzTrackId); } } + + private void AddExternalLyrics( + Audio audio, + List currentStreams, + MetadataRefreshOptions options) + { + var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1); + var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, false); + + audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray(); + currentStreams.AddRange(externalLyricFiles); + } } } diff --git a/MediaBrowser.Providers/MediaInfo/LyricResolver.cs b/MediaBrowser.Providers/MediaInfo/LyricResolver.cs new file mode 100644 index 000000000..52af5ea08 --- /dev/null +++ b/MediaBrowser.Providers/MediaInfo/LyricResolver.cs @@ -0,0 +1,39 @@ +using Emby.Naming.Common; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.MediaInfo; + +/// +/// Resolves external lyric files for . +/// +public class LyricResolver : MediaInfoResolver +{ + /// + /// Initializes a new instance of the class for external subtitle file processing. + /// + /// The logger. + /// The localization manager. + /// The media encoder. + /// The file system. + /// The object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters. + public LyricResolver( + ILogger logger, + ILocalizationManager localizationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + NamingOptions namingOptions) + : base( + logger, + localizationManager, + mediaEncoder, + fileSystem, + namingOptions, + DlnaProfileType.Lyric) + { + } +} diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index f846aa5de..fbec4e963 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Emby.Naming.Common; using Emby.Naming.ExternalFiles; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dlna; @@ -148,7 +149,49 @@ namespace MediaBrowser.Providers.MediaInfo } } - return mediaStreams.AsReadOnly(); + return mediaStreams; + } + + /// + /// Retrieves the external streams for the provided audio. + /// + /// The object to search external streams for. + /// The stream index to start adding external streams at. + /// The directory service to search for files. + /// True if the directory service cache should be cleared before searching. + /// The external streams located. + public IReadOnlyList GetExternalStreams( + Audio audio, + int startIndex, + IDirectoryService directoryService, + bool clearCache) + { + if (!audio.IsFileProtocol) + { + return Array.Empty(); + } + + var pathInfos = GetExternalFiles(audio, directoryService, clearCache); + + if (pathInfos.Count == 0) + { + return Array.Empty(); + } + + var mediaStreams = new MediaStream[pathInfos.Count]; + + for (var i = 0; i < pathInfos.Count; i++) + { + mediaStreams[i] = new MediaStream + { + Type = MediaStreamType.Lyric, + Path = pathInfos[i].Path, + Language = pathInfos[i].Language, + Index = startIndex++ + }; + } + + return mediaStreams; } /// @@ -209,6 +252,58 @@ namespace MediaBrowser.Providers.MediaInfo return externalPathInfos; } + /// + /// Returns the external file infos for the given audio. + /// + /// The object to search external files for. + /// The directory service to search for files. + /// True if the directory service cache should be cleared before searching. + /// The external file paths located. + public IReadOnlyList GetExternalFiles( + Audio audio, + IDirectoryService directoryService, + bool clearCache) + { + if (!audio.IsFileProtocol) + { + return Array.Empty(); + } + + string folder = audio.ContainingFolderPath; + var files = directoryService.GetFilePaths(folder, clearCache, true).ToList(); + files.Remove(audio.Path); + var internalMetadataPath = audio.GetInternalMetadataPath(); + if (_fileSystem.DirectoryExists(internalMetadataPath)) + { + files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true)); + } + + if (files.Count == 0) + { + return Array.Empty(); + } + + var externalPathInfos = new List(); + ReadOnlySpan prefix = audio.FileNameWithoutExtension; + foreach (var file in files) + { + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan()); + if (fileNameWithoutExtension.Length >= prefix.Length + && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase) + && (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[prefix.Length]))) + { + var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..].ToString()); + + if (externalPathInfo is not null) + { + externalPathInfos.Add(externalPathInfo); + } + } + } + + return externalPathInfos; + } + /// /// Returns the media info of the given file. /// diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 114a92975..8bb874f0d 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -43,6 +43,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly ILogger _logger; private readonly AudioResolver _audioResolver; private readonly SubtitleResolver _subtitleResolver; + private readonly LyricResolver _lyricResolver; private readonly FFProbeVideoInfo _videoProber; private readonly AudioFileProber _audioProber; private readonly Task _cachedTask = Task.FromResult(ItemUpdateType.None); @@ -79,9 +80,10 @@ namespace MediaBrowser.Providers.MediaInfo NamingOptions namingOptions) { _logger = loggerFactory.CreateLogger(); - _audioProber = new AudioFileProber(loggerFactory.CreateLogger(), mediaSourceManager, mediaEncoder, itemRepo, libraryManager); _audioResolver = new AudioResolver(loggerFactory.CreateLogger(), localization, mediaEncoder, fileSystem, namingOptions); _subtitleResolver = new SubtitleResolver(loggerFactory.CreateLogger(), localization, mediaEncoder, fileSystem, namingOptions); + _lyricResolver = new LyricResolver(loggerFactory.CreateLogger(), localization, mediaEncoder, fileSystem, namingOptions); + _videoProber = new FFProbeVideoInfo( loggerFactory.CreateLogger(), mediaSourceManager, @@ -96,6 +98,14 @@ namespace MediaBrowser.Providers.MediaInfo libraryManager, _audioResolver, _subtitleResolver); + + _audioProber = new AudioFileProber( + loggerFactory.CreateLogger(), + mediaSourceManager, + mediaEncoder, + itemRepo, + libraryManager, + _lyricResolver); } /// @@ -123,23 +133,37 @@ namespace MediaBrowser.Providers.MediaInfo } } - if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder - && !video.SubtitleFiles.SequenceEqual( - _subtitleResolver.GetExternalFiles(video, directoryService, false) - .Select(info => info.Path).ToList(), - StringComparer.Ordinal)) + if (video is not null + && item.SupportsLocalMetadata + && !video.IsPlaceHolder) { - _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path); - return true; + if (!video.SubtitleFiles.SequenceEqual( + _subtitleResolver.GetExternalFiles(video, directoryService, false) + .Select(info => info.Path).ToList(), + StringComparer.Ordinal)) + { + _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path); + return true; + } + + if (!video.AudioFiles.SequenceEqual( + _audioResolver.GetExternalFiles(video, directoryService, false) + .Select(info => info.Path).ToList(), + StringComparer.Ordinal)) + { + _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path); + return true; + } } - if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder - && !video.AudioFiles.SequenceEqual( - _audioResolver.GetExternalFiles(video, directoryService, false) - .Select(info => info.Path).ToList(), + if (item is Audio audio + && item.SupportsLocalMetadata + && !audio.LyricFiles.SequenceEqual( + _lyricResolver.GetExternalFiles(audio, directoryService, false) + .Select(info => info.Path).ToList(), StringComparer.Ordinal)) { - _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path); + _logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path); return true; } diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index 87fd2a3cd..f68b3cee6 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -74,7 +74,7 @@ namespace MediaBrowser.Providers.Subtitles .Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.Name, StringComparison.OrdinalIgnoreCase)) .OrderBy(i => { - var index = request.SubtitleFetcherOrder.ToList().IndexOf(i.Name); + var index = request.SubtitleFetcherOrder.IndexOf(i.Name); return index == -1 ? int.MaxValue : index; }) .ToArray(); diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index fd8f7e59a..9d8afc23c 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -61,6 +61,11 @@ namespace Jellyfin.Extensions /// The part left of the . public static ReadOnlySpan LeftPart(this ReadOnlySpan haystack, char needle) { + if (haystack.IsEmpty) + { + return ReadOnlySpan.Empty; + } + var pos = haystack.IndexOf(needle); return pos == -1 ? haystack : haystack[..pos]; } @@ -73,6 +78,11 @@ namespace Jellyfin.Extensions /// The part right of the . public static ReadOnlySpan RightPart(this ReadOnlySpan haystack, char needle) { + if (haystack.IsEmpty) + { + return ReadOnlySpan.Empty; + } + var pos = haystack.LastIndexOf(needle); if (pos == -1) { diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 1e0851993..478db6941 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -11,6 +11,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Configuration; @@ -570,7 +571,8 @@ namespace Jellyfin.Providers.Tests.Manager Mock.Of(), Mock.Of(), libraryManager.Object, - baseItemManager!); + baseItemManager!, + Mock.Of()); return providerManager; } -- cgit v1.2.3