From d1677dc680338679f06cc506e97f576d16d022b5 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Wed, 3 Jan 2024 16:47:25 +0100 Subject: AsyncKeyedLock migration --- Directory.Packages.props | 1 + Jellyfin.Api/Controllers/DynamicHlsController.cs | 133 +++++++++------------ Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs | 8 +- .../MediaEncoding/ITranscodeManager.cs | 5 +- .../Attachments/AttachmentExtractor.cs | 46 +++---- .../MediaBrowser.MediaEncoding.csproj | 3 +- .../Subtitles/SubtitleEncoder.cs | 52 +++----- .../Transcoding/TranscodeManager.cs | 46 ++++--- 8 files changed, 117 insertions(+), 177 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6b99ac807..30bd8ac55 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index dda1e9d56..590cdc33f 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -294,9 +294,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (!System.IO.File.Exists(playlistPath)) { - var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false)) { if (!System.IO.File.Exists(playlistPath)) { @@ -326,10 +324,6 @@ public class DynamicHlsController : BaseJellyfinApiController } } } - finally - { - transcodingLock.Release(); - } } job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); @@ -1442,95 +1436,80 @@ public class DynamicHlsController : BaseJellyfinApiController return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - var released = false; - var startTranscoding = false; - - try + using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false)) { + var startTranscoding = false; if (System.IO.File.Exists(segmentPath)) { job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - transcodingLock.Release(); - released = true; _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - else - { - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - - if (segmentId == -1) - { - _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); - startTranscoding = true; - segmentId = 0; - } - else if (currentTranscodingIndex is null) - { - _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); - startTranscoding = true; - } - else if (segmentId < currentTranscodingIndex.Value) - { - _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); - startTranscoding = true; - } - else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) - { - _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); - startTranscoding = true; - } - if (startTranscoding) - { - // If the playlist doesn't already exist, startup ffmpeg - try - { - await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) - .ConfigureAwait(false); + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - if (currentTranscodingIndex.HasValue) - { - DeleteLastFile(playlistPath, segmentExtension, 0); - } + if (segmentId == -1) + { + _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); + startTranscoding = true; + segmentId = 0; + } + else if (currentTranscodingIndex is null) + { + _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); + startTranscoding = true; + } + else if (segmentId < currentTranscodingIndex.Value) + { + _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); + startTranscoding = true; + } + else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) + { + _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); + startTranscoding = true; + } - streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + if (startTranscoding) + { + // If the playlist doesn't already exist, startup ffmpeg + try + { + await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) + .ConfigureAwait(false); - state.WaitForPath = segmentPath; - job = await _transcodeManager.StartFfMpeg( - state, - playlistPath, - GetCommandLineArguments(playlistPath, state, false, segmentId), - Request.HttpContext.User.GetUserId(), - TranscodingJobType, - cancellationTokenSource).ConfigureAwait(false); - } - catch + if (currentTranscodingIndex.HasValue) { - state.Dispose(); - throw; + DeleteLastFile(playlistPath, segmentExtension, 0); } - // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + + state.WaitForPath = segmentPath; + job = await _transcodeManager.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, false, segmentId), + Request.HttpContext.User.GetUserId(), + TranscodingJobType, + cancellationTokenSource).ConfigureAwait(false); } - else + catch { - job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - if (job?.TranscodingThrottler is not null) - { - await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); - } + state.Dispose(); + throw; } + + // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); } - } - finally - { - if (!released) + else { - transcodingLock.Release(); + job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + if (job?.TranscodingThrottler is not null) + { + await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); + } } } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 5385979d4..cb178a61d 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -93,9 +93,7 @@ public static class FileStreamResponseHelpers return new OkResult(); } - var transcodingLock = transcodeManager.GetTranscodingLock(outputPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - try + using (await transcodeManager.LockAsync(outputPath, cancellationTokenSource.Token).ConfigureAwait(false)) { TranscodingJob? job; if (!File.Exists(outputPath)) @@ -117,9 +115,5 @@ public static class FileStreamResponseHelpers var stream = new ProgressiveFileStream(outputPath, job, transcodeManager); return new FileStreamResult(stream, contentType); } - finally - { - transcodingLock.Release(); - } } } diff --git a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs index c19a12ae7..3b410d1ba 100644 --- a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs +++ b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs @@ -96,9 +96,10 @@ public interface ITranscodeManager public void OnTranscodeEndRequest(TranscodingJob job); /// - /// Gets the transcoding lock. + /// Transcoding lock. /// /// The output path of the transcoded file. + /// The cancellation token. /// A . - public SemaphoreSlim GetTranscodingLock(string outputPath); + ValueTask LockAsync(string outputPath, CancellationToken cancellationToken); } diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 299f294b2..ff91a60a7 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; @@ -22,7 +23,7 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Attachments { - public sealed class AttachmentExtractor : IAttachmentExtractor + public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable { private readonly ILogger _logger; private readonly IApplicationPaths _appPaths; @@ -30,8 +31,11 @@ namespace MediaBrowser.MediaEncoding.Attachments private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; - private readonly ConcurrentDictionary _semaphoreLocks = - new ConcurrentDictionary(); + private readonly AsyncKeyedLocker _semaphoreLocks = new(o => + { + o.PoolSize = 20; + o.PoolInitialFill = 1; + }); public AttachmentExtractor( ILogger logger, @@ -84,11 +88,7 @@ namespace MediaBrowser.MediaEncoding.Attachments string outputPath, CancellationToken cancellationToken) { - var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1)); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!Directory.Exists(outputPath)) { @@ -99,10 +99,6 @@ namespace MediaBrowser.MediaEncoding.Attachments cancellationToken).ConfigureAwait(false); } } - finally - { - semaphore.Release(); - } } public async Task ExtractAllAttachmentsExternal( @@ -111,11 +107,7 @@ namespace MediaBrowser.MediaEncoding.Attachments string outputPath, CancellationToken cancellationToken) { - var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1)); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(Path.Join(outputPath, id))) { @@ -131,10 +123,6 @@ namespace MediaBrowser.MediaEncoding.Attachments } } } - finally - { - semaphore.Release(); - } } private async Task ExtractAllAttachmentsInternal( @@ -256,11 +244,7 @@ namespace MediaBrowser.MediaEncoding.Attachments string outputPath, CancellationToken cancellationToken) { - var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1)); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(outputPath)) { @@ -271,10 +255,6 @@ namespace MediaBrowser.MediaEncoding.Attachments cancellationToken).ConfigureAwait(false); } } - finally - { - semaphore.Release(); - } } private async Task ExtractAttachmentInternal( @@ -379,5 +359,11 @@ namespace MediaBrowser.MediaEncoding.Attachments var prefix = filename.AsSpan(0, 1); return Path.Join(_appPaths.DataPath, "attachments", prefix, filename); } + + /// + public void Dispose() + { + _semaphoreLocks.Dispose(); + } } } diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index a4e8194c1..be63513a7 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -1,4 +1,4 @@ - + @@ -22,6 +22,7 @@ + diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 459d854bf..a546c80b4 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -1,7 +1,6 @@ #pragma warning disable CS1591 using System; -using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -11,6 +10,7 @@ using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -18,6 +18,7 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -27,7 +28,7 @@ using UtfUnknown; namespace MediaBrowser.MediaEncoding.Subtitles { - public sealed class SubtitleEncoder : ISubtitleEncoder + public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable { private readonly ILogger _logger; private readonly IApplicationPaths _appPaths; @@ -40,8 +41,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// /// The _semaphoreLocks. /// - private readonly ConcurrentDictionary _semaphoreLocks = - new ConcurrentDictionary(); + private readonly AsyncKeyedLocker _semaphoreLocks = new(o => + { + o.PoolSize = 20; + o.PoolInitialFill = 1; + }); public SubtitleEncoder( ILogger logger, @@ -317,16 +321,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw new ArgumentException("Unsupported format: " + format); } - /// - /// Gets the lock. - /// - /// The filename. - /// System.Object. - private SemaphoreSlim GetLock(string filename) - { - return _semaphoreLocks.GetOrAdd(filename, _ => new SemaphoreSlim(1, 1)); - } - /// /// Converts the text subtitle to SRT. /// @@ -337,21 +331,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// Task. private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken) { - var semaphore = GetLock(outputPath); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(outputPath)) { await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); } } - finally - { - semaphore.Release(); - } } /// @@ -484,16 +470,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles string outputPath, CancellationToken cancellationToken) { - var semaphore = GetLock(outputPath); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(outputPath)) { + var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); + var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource); if (subtitleStream.IsExternal) @@ -509,10 +491,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles cancellationToken).ConfigureAwait(false); } } - finally - { - semaphore.Release(); - } } private async Task ExtractTextSubtitleInternal( @@ -728,6 +706,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } + /// + public void Dispose() + { + _semaphoreLocks.Dispose(); + } + #pragma warning disable CA1034 // Nested types should not be visible // Only public for the unit tests public readonly record struct SubtitleInfo diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 483d0a1d8..db45d2cdd 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -4,10 +4,12 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Enums; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; @@ -42,7 +44,11 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable private readonly IAttachmentExtractor _attachmentExtractor; private readonly List _activeTranscodingJobs = new(); - private readonly Dictionary _transcodingLocks = new(); + private readonly AsyncKeyedLocker _transcodingLocks = new(o => + { + o.PoolSize = 20; + o.PoolInitialFill = 1; + }); /// /// Initializes a new instance of the class. @@ -223,11 +229,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } - lock (_transcodingLocks) - { - _transcodingLocks.Remove(job.Path!); - } - job.Stop(); if (delete(job.Path!)) @@ -624,11 +625,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } - lock (_transcodingLocks) - { - _transcodingLocks.Remove(path); - } - if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) { _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); @@ -704,21 +700,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } - /// - public SemaphoreSlim GetTranscodingLock(string outputPath) - { - lock (_transcodingLocks) - { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) - { - result = new SemaphoreSlim(1, 1); - _transcodingLocks[outputPath] = result; - } - - return result; - } - } - private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) { if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) @@ -741,10 +722,23 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } + /// + /// Transcoding lock. + /// + /// The output path of the transcoded file. + /// The cancellation token. + /// A . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ValueTask LockAsync(string outputPath, CancellationToken cancellationToken) + { + return _transcodingLocks.LockAsync(outputPath, cancellationToken); + } + /// public void Dispose() { _sessionManager.PlaybackProgress -= OnPlaybackProgress; _sessionManager.PlaybackStart -= OnPlaybackProgress; + _transcodingLocks.Dispose(); } } -- cgit v1.2.3 From 39088b5ad29cf098729c31f0be90a387df5debf6 Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo Date: Mon, 25 Dec 2023 20:52:39 +0300 Subject: fix: discard webm if there is an unsupported codec --- .../Probing/ProbeResultNormalizer.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 629c30060..1ec0622f8 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -30,6 +30,8 @@ namespace MediaBrowser.MediaEncoding.Probing private const string ArtistReplaceValue = " | "; private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; + private readonly string[] _webmVideoCodecs = { "vp8", "vp9" }; + private readonly string[] _webmAudioCodecs = { "opus", "vorbis" }; private readonly ILogger _logger; private readonly ILocalizationManager _localization; @@ -114,7 +116,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (data.Format is not null) { - info.Container = NormalizeFormat(data.Format.FormatName); + info.Container = NormalizeFormat(data.Format.FormatName, info.MediaStreams); if (int.TryParse(data.Format.BitRate, CultureInfo.InvariantCulture, out var value)) { @@ -260,7 +262,7 @@ namespace MediaBrowser.MediaEncoding.Probing return info; } - private string NormalizeFormat(string format) + private string NormalizeFormat(string format, IReadOnlyList mediaStreams) { if (string.IsNullOrWhiteSpace(format)) { @@ -288,9 +290,20 @@ namespace MediaBrowser.MediaEncoding.Probing { splitFormat[i] = "mkv"; } + + // Handle WebM + else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase)) + { + // Limit WebM to supported codecs + if (mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)) + || (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)))) + { + splitFormat[i] = string.Empty; + } + } } - return string.Join(',', splitFormat); + return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s))); } private int? GetEstimatedAudioBitrate(string codec, int? channels) -- cgit v1.2.3 From 366a22da7162560e3010d3a4722f6544149c083f Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo Date: Mon, 25 Dec 2023 20:54:00 +0300 Subject: test: add webm test --- .../Probing/ProbeResultNormalizerTests.cs | 17 ++++ .../Test Data/Probing/video_webm.json | 106 +++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_webm.json diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 344ac8971..020e20fb8 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -46,6 +46,8 @@ namespace Jellyfin.MediaEncoding.Tests.Probing var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File); + Assert.Equal("mkv", res.Container); + Assert.Equal(3, res.MediaStreams.Count); Assert.NotNull(res.VideoStream); @@ -177,6 +179,21 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.False(res.MediaStreams[0].IsAVC); } + [Fact] + public void GetMediaInfo_WebM_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/video_webm.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.webm", MediaProtocol.File); + + Assert.Equal("mkv,webm", res.Container); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.False(res.MediaStreams[0].IsAVC); + } + [Fact] public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success() { diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_webm.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_webm.json new file mode 100644 index 000000000..4cc7db80d --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_webm.json @@ -0,0 +1,106 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "vp8", + "codec_long_name": "On2 VP8", + "profile": "1", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 540, + "height": 360, + "coded_width": 540, + "coded_height": 360, + "closed_captions": 0, + "film_grain": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "3:2", + "pix_fmt": "yuv420p", + "level": -99, + "field_order": "progressive", + "refs": 1, + "r_frame_rate": "2997/125", + "avg_frame_rate": "2997/125", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng" + } + }, + { + "index": 1, + "codec_name": "vorbis", + "codec_long_name": "Vorbis", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "fltp", + "sample_rate": "44100", + "channels": 1, + "channel_layout": "mono", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "extradata_size": 3097, + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng" + } + } + ], + "format": { + "filename": "sample.webm", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "117.700914", + "size": "8566268", + "bit_rate": "582239", + "probe_score": 100 + } +} -- cgit v1.2.3 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(-) 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(-) 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(-) 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 420ce6a4faad81be9e9ef5e1f416c51301912049 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 13:45:02 +0100 Subject: Fixed issue --- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 7d5ec615a..92c3f57c8 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -903,9 +903,8 @@ namespace MediaBrowser.MediaEncoding.Encoder using (var processWrapper = new ProcessWrapper(process, this)) { bool ranToCompletion = false; - - await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - try + + using (await _thumbnailResourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { StartProcess(processWrapper); @@ -959,10 +958,6 @@ namespace MediaBrowser.MediaEncoding.Encoder StopProcess(processWrapper, 1000); } } - finally - { - _thumbnailResourcePool.Release(); - } var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1; -- cgit v1.2.3 From 7998e15839ca9058ea05e41132c7858daf261493 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 14:13:17 +0100 Subject: Update MediaEncoder.cs --- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 92c3f57c8..b39ef3eaa 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -902,8 +902,8 @@ namespace MediaBrowser.MediaEncoding.Encoder using (var processWrapper = new ProcessWrapper(process, this)) { - bool ranToCompletion = false; - + bool ranToCompletion = false: + using (await _thumbnailResourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { StartProcess(processWrapper); -- cgit v1.2.3 From cc42f4430f522ae055cffd3ab4aeb137d79d51b0 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 14:16:20 +0100 Subject: Update MediaEncoder.cs --- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index b39ef3eaa..f86d14fc8 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -902,7 +902,7 @@ namespace MediaBrowser.MediaEncoding.Encoder using (var processWrapper = new ProcessWrapper(process, this)) { - bool ranToCompletion = false: + bool ranToCompletion = false; using (await _thumbnailResourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { -- cgit v1.2.3 From ebedb06e40853137459c9bdf91e601c8f84120da Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 21:53:15 +0100 Subject: Bump AsyncKeyedLock to 6.3.3 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 29b9030ac..fe251341a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - + -- cgit v1.2.3 From 7cd60aefb50d2868eb584c60967d459cdca8f80a Mon Sep 17 00:00:00 2001 From: Martin Vandenbussche Date: Mon, 15 Jan 2024 16:19:47 +0100 Subject: Adding support for proper trailer STRM URL format, along with the deprecated format --- MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs | 26 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 70e5b66c1..5408fb640 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -460,10 +460,28 @@ namespace MediaBrowser.XbmcMetadata.Parsers var trailer = reader.ReadNormalizedString(); if (!string.IsNullOrEmpty(trailer)) { - item.AddTrailerUrl(trailer.Replace( - "plugin://plugin.video.youtube/?action=play_video&videoid=", - BaseNfoSaver.YouTubeWatchUrl, - StringComparison.OrdinalIgnoreCase)); + if (trailer.StartsWith("plugin://plugin.video.youtube/?action=play_video&videoid=", StringComparison.OrdinalIgnoreCase)) + { + // Deprecated format + item.AddTrailerUrl(trailer.Replace( + "plugin://plugin.video.youtube/?action=play_video&videoid=", + BaseNfoSaver.YouTubeWatchUrl, + StringComparison.OrdinalIgnoreCase)); + + var suggested_url = trailer.Replace( + "plugin://plugin.video.youtube/?action=play_video&videoid=", + "plugin://plugin.video.youtube/play/?video_id=", + StringComparison.OrdinalIgnoreCase); + Logger.LogWarning("Trailer URL uses a deprecated format : {URL}. Using {URL_NEW} instead is advised.", [trailer, suggested_url]); + } + else if (trailer.StartsWith("plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase)) + { + // Proper format + item.AddTrailerUrl(trailer.Replace( + "plugin://plugin.video.youtube/play/?video_id=", + BaseNfoSaver.YouTubeWatchUrl, + StringComparison.OrdinalIgnoreCase)); + } } break; -- cgit v1.2.3 From c03f5ca6c32d14681921fa084618ca89fbe953ab Mon Sep 17 00:00:00 2001 From: Martin Vandenbussche Date: Mon, 15 Jan 2024 16:23:15 +0100 Subject: Updating CONTRIBUTORS.md --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4e45fd24a..3752ba1b0 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -175,6 +175,7 @@ - [Chris-Codes-It] (https://github.com/Chris-Codes-It) - [Pithaya](https://github.com/Pithaya) - [Çağrı Sakaoğlu](https://github.com/ilovepilav) + _ [Barasingha](https://github.com/MaVdbussche) # Emby Contributors -- cgit v1.2.3 From 9ff9c8f0c784044119e021ac798b6e40e104de91 Mon Sep 17 00:00:00 2001 From: Martin Vandenbussche <26136934+MaVdbussche@users.noreply.github.com> Date: Tue, 16 Jan 2024 08:50:39 +0100 Subject: Apply suggestions from code review Co-authored-by: Cody Robibero --- MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 5408fb640..ec2bdb1e7 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -468,11 +468,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers BaseNfoSaver.YouTubeWatchUrl, StringComparison.OrdinalIgnoreCase)); - var suggested_url = trailer.Replace( + var suggestedUrl = trailer.Replace( "plugin://plugin.video.youtube/?action=play_video&videoid=", "plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase); - Logger.LogWarning("Trailer URL uses a deprecated format : {URL}. Using {URL_NEW} instead is advised.", [trailer, suggested_url]); + Logger.LogWarning("Trailer URL uses a deprecated format : {Url}. Using {NewUrl} instead is advised.", [trailer, suggestedUrl]); } else if (trailer.StartsWith("plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase)) { -- 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 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 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 27ab3ef029cc65d3a60812615350de8cd2f5fda4 Mon Sep 17 00:00:00 2001 From: Martin Vandenbussche Date: Wed, 17 Jan 2024 16:46:04 +0100 Subject: Removing unnecessary array initialization --- MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index ec2bdb1e7..97cdc6854 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -472,7 +472,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers "plugin://plugin.video.youtube/?action=play_video&videoid=", "plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase); - Logger.LogWarning("Trailer URL uses a deprecated format : {Url}. Using {NewUrl} instead is advised.", [trailer, suggestedUrl]); + Logger.LogWarning("Trailer URL uses a deprecated format : {Url}. Using {NewUrl} instead is advised.", trailer, suggestedUrl); } else if (trailer.StartsWith("plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase)) { -- 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(-) 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(-) 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(-) 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(-) 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 08592fb3fec648aff1b5cf64d03d3339b200cac9 Mon Sep 17 00:00:00 2001 From: TelepathicWalrus Date: Wed, 17 Jan 2024 19:36:14 +0000 Subject: Add ex to catch if cached mediainfo doesnt exist --- .../Library/LiveStreamHelper.cs | 23 +++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 59d705ace..d41845cdf 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -48,20 +48,25 @@ namespace Emby.Server.Implementations.Library if (!string.IsNullOrEmpty(cacheKey)) { - FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); try { - mediaInfo = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); - // _logger.LogDebug("Found cached media info"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deserializing mediainfo cache"); + try + { + mediaInfo = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + // _logger.LogDebug("Found cached media info"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deserializing mediainfo cache"); + } + + await jsonStream.DisposeAsync().ConfigureAwait(false); } - finally + catch { - await jsonStream.DisposeAsync().ConfigureAwait(false); + _logger.LogError("Could not open cached media info"); } } -- cgit v1.2.3 From ba877283a17f9f1ef32569669989e6d72cc571c5 Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo <56478732+dmitrylyzo@users.noreply.github.com> Date: Thu, 18 Jan 2024 08:01:06 +0300 Subject: fix: add av1 to webm At least AV1 in WebM is supported by Chrome and Firefox. --- MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 1ec0622f8..dba7aea6f 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -30,7 +30,7 @@ namespace MediaBrowser.MediaEncoding.Probing private const string ArtistReplaceValue = " | "; private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; - private readonly string[] _webmVideoCodecs = { "vp8", "vp9" }; + private readonly string[] _webmVideoCodecs = { "av1", "vp8", "vp9" }; private readonly string[] _webmAudioCodecs = { "opus", "vorbis" }; private readonly ILogger _logger; -- cgit v1.2.3 From 8fea819b5152f6a38febb9435df18c2fa26d3273 Mon Sep 17 00:00:00 2001 From: Attila Szakacs Date: Thu, 18 Jan 2024 16:38:47 +0100 Subject: Extract all subtitle streams simultaneously Extracting a subtitle stream is a disk I/O bottlenecked operation as ffmpeg has to read through the whole file, but usually there is nothing CPU intensive to do. If a file has multiple subtitle streams, and we want to extract more of them, extracting them one-by-one results in reading the whole file again and again. However ffmpeg can extract multiple streams at once. We can optimize this by extracting the subtitle streams all at once when only one of them gets queried, then we will have all of them cached for later use. It is useful for people switching subtitles during playback. It is even more useful for people who extract all the subtitle streams in advance, for example with the "Subtitle Extract" plugin. In this case we reduce the extraction time significantly based on the number of subtitle streams in the files, which can be 5-10 in many cases. Signed-off-by: Attila Szakacs --- .../Subtitles/SubtitleEncoder.cs | 221 ++++++++++++++++++--- 1 file changed, 194 insertions(+), 27 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 459d854bf..0e66565ed 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -194,36 +195,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles { if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - string outputFormat; - string outputCodec; + await ExtractAllTextSubtitles(mediaSource, cancellationToken).ConfigureAwait(false); - if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) - || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase) - || string.Equals(subtitleStream.Codec, "srt", StringComparison.OrdinalIgnoreCase)) - { - // Extract - outputCodec = "copy"; - outputFormat = subtitleStream.Codec; - } - else if (string.Equals(subtitleStream.Codec, "subrip", StringComparison.OrdinalIgnoreCase)) - { - // Extract - outputCodec = "copy"; - outputFormat = "srt"; - } - else - { - // Extract - outputCodec = "srt"; - outputFormat = "srt"; - } - - // Extract + var outputFormat = GetTextSubtitleFormat(subtitleStream); var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat); - await ExtractTextSubtitle(mediaSource, subtitleStream, outputCodec, outputPath, cancellationToken) - .ConfigureAwait(false); - return new SubtitleInfo() { Path = outputPath, @@ -467,6 +443,197 @@ namespace MediaBrowser.MediaEncoding.Subtitles _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); } + private string GetTextSubtitleFormat(MediaStream subtitleStream) + { + if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)) + { + return subtitleStream.Codec; + } + else + { + return "srt"; + } + } + + private bool IsCodecCopyable(string codec) + { + return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Extracts all text subtitles. + /// + /// The mediaSource. + /// The cancellation token. + /// Task. + private async Task ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) + { + var semaphores = new List { }; + var extractableStreams = new List { }; + + try + { + var subtitleStreams = mediaSource.MediaStreams + .Where(stream => stream.IsTextSubtitleStream && stream.SupportsExternalStream); + + foreach (var subtitleStream in subtitleStreams) + { + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); + + var semaphore = GetLock(outputPath); + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (File.Exists(outputPath)) + { + semaphore.Release(); + continue; + } + + semaphores.Add(semaphore); + extractableStreams.Add(subtitleStream); + } + + if (extractableStreams.Count > 0) + { + await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path); + } + finally + { + foreach (var semaphore in semaphores) + { + semaphore.Release(); + } + } + } + + private async Task ExtractAllTextSubtitlesInternal( + MediaSourceInfo mediaSource, + List subtitleStreams, + CancellationToken cancellationToken) + { + var inputPath = mediaSource.Path; + var outputPaths = new List { }; + var args = string.Format( + CultureInfo.InvariantCulture, + "-i {0} -copyts", + inputPath); + + foreach (var subtitleStream in subtitleStreams) + { + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); + var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid.")); + + outputPaths.Add(outputPath); + args += string.Format( + CultureInfo.InvariantCulture, + " -map 0:{0} -an -vn -c:s {1} \"{2}\"", + subtitleStream.Index, + outputCodec, + outputPath); + } + + int exitCode; + + using (var process = new Process + { + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + FileName = _mediaEncoder.EncoderPath, + Arguments = args, + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }, + EnableRaisingEvents = true + }) + { + _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting ffmpeg"); + + throw; + } + + try + { + await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; + } + } + + var failed = false; + + if (exitCode == -1) + { + failed = true; + + foreach (var outputPath in outputPaths) + { + try + { + _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath); + _fileSystem.DeleteFile(outputPath); + } + catch (FileNotFoundException) + { + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); + } + } + } + else + { + foreach (var outputPath in outputPaths) + { + if (!File.Exists(outputPath)) + { + _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); + failed = true; + } + else + { + if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase)) + { + await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); + } + } + } + + if (failed) + { + throw new FfmpegException( + string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath)); + } + } + /// /// Extracts the text subtitle. /// -- cgit v1.2.3 From 21ae7a1317255acd0a031e74575f6c08967ce5f3 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Thu, 18 Jan 2024 23:11:22 +0000 Subject: Added ffmpeg version to build --- .../Dev - Server Default/devcontainer.json | 28 ++++++++++++++++++++++ .../Dev - Server Ffmpeg/devcontainer.json | 28 ++++++++++++++++++++++ .../Dev - Server Ffmpeg/install-ffmpeg.sh | 5 ++++ .devcontainer/devcontainer.json | 28 ---------------------- .vscode/extensions.json | 2 +- 5 files changed, 62 insertions(+), 29 deletions(-) create mode 100644 .devcontainer/Dev - Server Default/devcontainer.json create mode 100644 .devcontainer/Dev - Server Ffmpeg/devcontainer.json create mode 100644 .devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh delete mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dev - Server Default/devcontainer.json b/.devcontainer/Dev - Server Default/devcontainer.json new file mode 100644 index 000000000..063901c80 --- /dev/null +++ b/.devcontainer/Dev - Server Default/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "Development Jellyfin Server", + "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", + // restores nuget packages, installs the dotnet workloads and installs the dev https certificate + "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust", + // reads the extensions list and installs them + "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", + "features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "none", + "dotnetRuntimeVersions": "8.0", + "aspNetCoreRuntimeVersions": "8.0" + }, + "ghcr.io/devcontainers-contrib/features/apt-packages:1": { + "preserve_apt_list": false, + "packages": ["libfontconfig1"] + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} + }, + "hostRequirements": { + "memory": "8gb", + "cpus": 4 + } +} diff --git a/.devcontainer/Dev - Server Ffmpeg/devcontainer.json b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json new file mode 100644 index 000000000..8d1413d09 --- /dev/null +++ b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "Development Jellyfin Server - FFmpeg", + "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", + // restores nuget packages, installs the dotnet workloads and installs the dev https certificate + "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust; bash ./install-ffmpeg.sh", + // reads the extensions list and installs them + "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", + "features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "none", + "dotnetRuntimeVersions": "8.0", + "aspNetCoreRuntimeVersions": "8.0" + }, + "ghcr.io/devcontainers-contrib/features/apt-packages:1": { + "preserve_apt_list": false, + "packages": ["libfontconfig1"] + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} + }, + "hostRequirements": { + "memory": "8gb", + "cpus": 4 + } +} diff --git a/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh b/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh new file mode 100644 index 000000000..d2a54b98f --- /dev/null +++ b/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +sudo wget https://repo.jellyfin.org/releases/server/ubuntu/versions/jellyfin-ffmpeg/6.0-8/jellyfin-ffmpeg6_6.0-8-focal_amd64.deb -O ffmpeg.deb +sudo apt install -f ./ffmpeg.deb -y +rm ffmpeg.deb \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 063901c80..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Development Jellyfin Server", - "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", - // restores nuget packages, installs the dotnet workloads and installs the dev https certificate - "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust", - // reads the extensions list and installs them - "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", - "features": { - "ghcr.io/devcontainers/features/dotnet:2": { - "version": "none", - "dotnetRuntimeVersions": "8.0", - "aspNetCoreRuntimeVersions": "8.0" - }, - "ghcr.io/devcontainers-contrib/features/apt-packages:1": { - "preserve_apt_list": false, - "packages": ["libfontconfig1"] - }, - "ghcr.io/devcontainers/features/docker-in-docker:2": { - "dockerDashComposeVersion": "v2" - }, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} - }, - "hostRequirements": { - "memory": "8gb", - "cpus": 4 - } -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d738e9fba..3be946e44 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,7 +2,7 @@ "recommendations": [ "ms-dotnettools.csharp", "editorconfig.editorconfig", - "GitHub.vscode-github-actions", + "github.vscode-github-actions", "ms-dotnettools.vscode-dotnet-runtime", "ms-dotnettools.csdevkit" ], -- cgit v1.2.3 From 23c77706838aa2e5cf83ee036f3853a90acc4a68 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 19 Jan 2024 00:48:03 +0000 Subject: Fixed ffmpeg version updated lauch with ffmpeg --- .../Dev - Server Default/devcontainer.json | 28 ---------------------- .../Dev - Server Ffmpeg/devcontainer.json | 2 +- .../Dev - Server Ffmpeg/install-ffmpeg.sh | 6 +++-- .devcontainer/devcontainer.json | 28 ++++++++++++++++++++++ .vscode/launch.json | 12 ++++++++++ 5 files changed, 45 insertions(+), 31 deletions(-) delete mode 100644 .devcontainer/Dev - Server Default/devcontainer.json create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dev - Server Default/devcontainer.json b/.devcontainer/Dev - Server Default/devcontainer.json deleted file mode 100644 index 063901c80..000000000 --- a/.devcontainer/Dev - Server Default/devcontainer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Development Jellyfin Server", - "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", - // restores nuget packages, installs the dotnet workloads and installs the dev https certificate - "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust", - // reads the extensions list and installs them - "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", - "features": { - "ghcr.io/devcontainers/features/dotnet:2": { - "version": "none", - "dotnetRuntimeVersions": "8.0", - "aspNetCoreRuntimeVersions": "8.0" - }, - "ghcr.io/devcontainers-contrib/features/apt-packages:1": { - "preserve_apt_list": false, - "packages": ["libfontconfig1"] - }, - "ghcr.io/devcontainers/features/docker-in-docker:2": { - "dockerDashComposeVersion": "v2" - }, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} - }, - "hostRequirements": { - "memory": "8gb", - "cpus": 4 - } -} diff --git a/.devcontainer/Dev - Server Ffmpeg/devcontainer.json b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json index 8d1413d09..0b848d9f3 100644 --- a/.devcontainer/Dev - Server Ffmpeg/devcontainer.json +++ b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json @@ -2,7 +2,7 @@ "name": "Development Jellyfin Server - FFmpeg", "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", // restores nuget packages, installs the dotnet workloads and installs the dev https certificate - "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust; bash ./install-ffmpeg.sh", + "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust; sudo bash \"./.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh\"", // reads the extensions list and installs them "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", "features": { diff --git a/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh b/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh index d2a54b98f..c84e1258f 100644 --- a/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh +++ b/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh @@ -1,5 +1,7 @@ #!/bin/bash -sudo wget https://repo.jellyfin.org/releases/server/ubuntu/versions/jellyfin-ffmpeg/6.0-8/jellyfin-ffmpeg6_6.0-8-focal_amd64.deb -O ffmpeg.deb +wget https://repo.jellyfin.org/releases/server/ubuntu/versions/jellyfin-ffmpeg/6.0.1-1/jellyfin-ffmpeg6_6.0.1-1-jammy_amd64.deb -O ffmpeg.deb + +sudo apt update sudo apt install -f ./ffmpeg.deb -y -rm ffmpeg.deb \ No newline at end of file +rm ffmpeg.deb diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..063901c80 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "Development Jellyfin Server", + "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", + // restores nuget packages, installs the dotnet workloads and installs the dev https certificate + "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust", + // reads the extensions list and installs them + "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", + "features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "none", + "dotnetRuntimeVersions": "8.0", + "aspNetCoreRuntimeVersions": "8.0" + }, + "ghcr.io/devcontainers-contrib/features/apt-packages:1": { + "preserve_apt_list": false, + "packages": ["libfontconfig1"] + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} + }, + "hostRequirements": { + "memory": "8gb", + "cpus": 4 + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json index be55764fd..2673973db 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,6 +29,18 @@ "stopAtEntry": false, "internalConsoleOptions": "openOnSessionStart" }, + { + "name": "ghcs .NET Launch (nowebclient, ffmpeg)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net8.0/jellyfin.dll", + "args": ["--nowebclient", "--ffmpeg", "/usr/share/jellyfin-ffmpeg/ffmpeg"], + "cwd": "${workspaceFolder}/Jellyfin.Server", + "console": "internalConsole", + "stopAtEntry": false, + "internalConsoleOptions": "openOnSessionStart" + }, { "name": ".NET Attach", "type": "coreclr", -- cgit v1.2.3 From 8de70e381485e20378538a58dd7476f10cbab60a Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 19 Jan 2024 06:02:24 +0000 Subject: Added documentation --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 62ef21334..3f6ccb7b7 100644 --- a/README.md +++ b/README.md @@ -145,14 +145,24 @@ cd Jellyfin.Server/bin/Debug/net8.0 # Change into the build output directory ### Running from GH-Codespaces As Jellyfin will run on a container on a github hosted server, JF needs to handle some things differently. -**NOTE:** If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VsCode window to public. -#### FFmpeg installation. -Because sometimes you need FFMPEG to test certain cases, follow the instructions from the wiki on the dev enviorment: -https://jellyfin.org/docs/general/installation/linux/#ffmpeg-installation +**NOTE:** Depending on the selected configuration (if you just click 'create codespace' it will create a default configuration one) it might take 20-30 secounds to load all extensions and prepare the enviorment while vscode is already open. Just give it some time and wait until you see `Downloading .NET version(s) 7.0.15~x64 ...... Done!` in the output tab. + +**NOTE:** If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VsCode window to public. **NOTE:** When first opening the server instance with any WebUI, you will be send to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup. +There are two configurations for you to chose from. +#### Default - Development Jellyfin Server +This creates a container that has everything to run and debug the Jellyfin Media server but does not setup anything else. Each time you create a new container you have to run though the whole setup again. There is also no ffmpeg, webclient or media preloaded. Use the `.NET Launch (nowebclient)` lunch config to start the server. + +> Keep in mind that as this has no web client you have to connect to it via an extenal client. This can be just another codespace container running the WebUI. vuejs does not work from the getgo as it does not support the setup steps. + +#### Development Jellyfin Server ffmpeg +this extens the default server with an default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual +If you want to install a specific ffmpeg version, follow the comments embedded in the `.devcontainer/Dev - Server Ffmpeg/install.ffmpeg.sh` file. + + ### Running The Tests This repository also includes unit tests that are used to validate functionality as part of a CI pipeline on Azure. There are several ways to run these tests. -- cgit v1.2.3 From 6ef110b00803e8dad37375d80c98a767859f6c1b Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 19 Jan 2024 06:02:39 +0000 Subject: Updated to using jf repro --- .../Dev - Server Ffmpeg/install-ffmpeg.sh | 33 +++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh b/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh index c84e1258f..c867ef538 100644 --- a/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh +++ b/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh @@ -1,7 +1,32 @@ #!/bin/bash -wget https://repo.jellyfin.org/releases/server/ubuntu/versions/jellyfin-ffmpeg/6.0.1-1/jellyfin-ffmpeg6_6.0.1-1-jammy_amd64.deb -O ffmpeg.deb +## configure the following for a manuall install of a specific version from the repo -sudo apt update -sudo apt install -f ./ffmpeg.deb -y -rm ffmpeg.deb +# wget https://repo.jellyfin.org/releases/server/ubuntu/versions/jellyfin-ffmpeg/6.0.1-1/jellyfin-ffmpeg6_6.0.1-1-jammy_amd64.deb -O ffmpeg.deb + +# sudo apt update +# sudo apt install -f ./ffmpeg.deb -y +# rm ffmpeg.deb + + +## Add the jellyfin repo +sudo apt install curl gnupg -y +sudo apt-get install software-properties-common -y +sudo add-apt-repository universe -y + +sudo mkdir -p /etc/apt/keyrings +curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg +export VERSION_OS="$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release )" +export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )" +export DPKG_ARCHITECTURE="$( dpkg --print-architecture )" +cat < Date: Fri, 19 Jan 2024 07:39:04 +0100 Subject: Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3f6ccb7b7..878e335ed 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ This creates a container that has everything to run and debug the Jellyfin Media this extens the default server with an default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual If you want to install a specific ffmpeg version, follow the comments embedded in the `.devcontainer/Dev - Server Ffmpeg/install.ffmpeg.sh` file. +Use the `ghcs .NET Launch (nowebclient, ffmpeg)` launch config to run with the jellyfin-ffmpeg enabled. + ### Running The Tests -- cgit v1.2.3 From 38bf59d6e8485c05b6b877043b6e87ff09e80d45 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 19 Jan 2024 12:25:39 +0100 Subject: Update .vscode/launch.json Co-authored-by: Nyanmisaka --- .vscode/launch.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 2673973db..7e50d4f0a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,7 +35,7 @@ "request": "launch", "preLaunchTask": "build", "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net8.0/jellyfin.dll", - "args": ["--nowebclient", "--ffmpeg", "/usr/share/jellyfin-ffmpeg/ffmpeg"], + "args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", "stopAtEntry": false, -- cgit v1.2.3 From 538f141b4cd7c3537f4a60060d54c51f7e19afcf Mon Sep 17 00:00:00 2001 From: TelepathicWalrus Date: Fri, 19 Jan 2024 17:25:57 +0000 Subject: Update error handling --- Emby.Server.Implementations/Library/LiveStreamHelper.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index d41845cdf..5f54c7319 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -64,9 +64,13 @@ namespace Emby.Server.Implementations.Library await jsonStream.DisposeAsync().ConfigureAwait(false); } - catch + catch (IOException) { - _logger.LogError("Could not open cached media info"); + _logger.LogDebug("Could not open cached media info"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error opening cached media info"); } } -- cgit v1.2.3 From 1d235205ae0a53fd7e584b6561dd0f0c6437f691 Mon Sep 17 00:00:00 2001 From: TelepathicWalrus Date: Mon, 22 Jan 2024 17:43:35 +0000 Subject: Log IOException --- Emby.Server.Implementations/Library/LiveStreamHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 5f54c7319..d6530df2d 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -64,9 +64,9 @@ namespace Emby.Server.Implementations.Library await jsonStream.DisposeAsync().ConfigureAwait(false); } - catch (IOException) + catch (IOException ex) { - _logger.LogDebug("Could not open cached media info"); + _logger.LogDebug(ex, "Could not open cached media info"); } catch (Exception ex) { -- cgit v1.2.3 From 179965e7749794513be0b832e9d8fb31444e9779 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Tue, 23 Jan 2024 12:11:13 +0100 Subject: Bump AsyncKeyedLock to 6.3.4; performance improvement --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index fe251341a..6e257c0ac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - + -- cgit v1.2.3 From 47ba39062f58f96d9e74ae61293f985729c2b991 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 25 Jan 2024 12:09:48 +0000 Subject: chore(deps): update peter-evans/create-or-update-comment action to v4 --- .github/workflows/ci-openapi.yml | 4 ++-- .github/workflows/commands.yml | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index e43160562..e8168470b 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -112,7 +112,7 @@ jobs: direction: last body-includes: openapi-diff-workflow-comment - name: Reply or edit difference comment (changed) - uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 if: ${{ steps.read-diff.outputs.body != '' }} with: issue-number: ${{ github.event.pull_request.number }} @@ -127,7 +127,7 @@ jobs: - name: Edit difference comment (unchanged) - uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 75b6a73e5..386f8d321 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 with: token: ${{ secrets.JF_BOT_TOKEN }} comment-id: ${{ github.event.comment.id }} @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -58,7 +58,7 @@ jobs: - name: Notify as running id: comment_running - uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -93,7 +93,7 @@ jobs: exit ${retcode} - name: Notify with result success - uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 if: ${{ github.event.comment != null && success() }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -108,7 +108,7 @@ jobs: reactions: hooray - name: Notify with result failure - uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 if: ${{ github.event.comment != null && failure() }} with: token: ${{ secrets.JF_BOT_TOKEN }} -- cgit v1.2.3 From d1a298138309e637c4012cca24025960074d9e51 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 25 Jan 2024 12:09:55 +0000 Subject: chore(deps): update peter-evans/find-comment action to v3 --- .github/workflows/ci-openapi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index e43160562..7ea3bc3bf 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -105,7 +105,7 @@ jobs: body="${body//$'\r'/'%0D'}" echo ::set-output name=body::$body - name: Find difference comment - uses: peter-evans/find-comment@a54c31d7fa095754bfef525c0c8e5e5674c4b4b1 # v2.4.0 + uses: peter-evans/find-comment@d5fe37641ad8451bdd80312415672ba26c86575e # v3.0.0 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} -- 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(-) 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 2d68e0b7e7038a9ba3746f4ee7b864da4987625b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 14:56:37 +0000 Subject: chore(deps): update github/codeql-action action to v3.23.2 --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index d8c550e70..e92a404d2 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/init@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/autobuild@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/analyze@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 -- cgit v1.2.3 From 9323390add9fe23d2e5b71826b59360f9604f086 Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Sun, 28 Jan 2024 19:40:49 +0800 Subject: Fix the display aspect ratio of PGSSUB subtitle burn-in Signed-off-by: nyanmisaka --- .../MediaEncoding/EncodingHelper.cs | 88 ++++++++++------------ .../Probing/ProbeResultNormalizer.cs | 4 + 2 files changed, 45 insertions(+), 47 deletions(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 400e7f40f..cffc014e8 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -835,30 +835,25 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetGraphicalSubCanvasSize(EncodingJobInfo state) { - // DVBSUB and DVDSUB use the fixed canvas size 720x576 + // DVBSUB uses the fixed canvas size 720x576 if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode && !state.SubtitleStream.IsTextSubtitleStream - && !string.Equals(state.SubtitleStream.Codec, "DVBSUB", StringComparison.OrdinalIgnoreCase) - && !string.Equals(state.SubtitleStream.Codec, "DVDSUB", StringComparison.OrdinalIgnoreCase)) + && !string.Equals(state.SubtitleStream.Codec, "DVBSUB", StringComparison.OrdinalIgnoreCase)) { - var inW = state.VideoStream?.Width; - var inH = state.VideoStream?.Height; - var reqW = state.BaseRequest.Width; - var reqH = state.BaseRequest.Height; - var reqMaxW = state.BaseRequest.MaxWidth; - var reqMaxH = state.BaseRequest.MaxHeight; + var subtitleWidth = state.SubtitleStream?.Width; + var subtitleHeight = state.SubtitleStream?.Height; - // setup a relative small canvas_size for overlay_qsv/vaapi to reduce transfer overhead - var (overlayW, overlayH) = GetFixedOutputSize(inW, inH, reqW, reqH, reqMaxW, 1080); - - if (overlayW.HasValue && overlayH.HasValue) + if (subtitleWidth.HasValue + && subtitleHeight.HasValue + && subtitleWidth.Value > 0 + && subtitleHeight.Value > 0) { return string.Format( CultureInfo.InvariantCulture, " -canvas_size {0}x{1}", - overlayW.Value, - overlayH.Value); + subtitleWidth.Value, + subtitleHeight.Value); } } @@ -2877,7 +2872,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Empty; } - public static string GetCustomSwScaleFilter( + public static string GetGraphicalSubPreProcessFilters( int? videoWidth, int? videoHeight, int? requestedWidth, @@ -2897,7 +2892,7 @@ namespace MediaBrowser.Controller.MediaEncoding { return string.Format( CultureInfo.InvariantCulture, - "scale=s={0}x{1}:flags=fast_bilinear", + @"scale=-1:{1}:fast_bilinear,crop,pad=max({0}\,iw):max({1}\,ih):(ow-iw)/2:(oh-ih)/2:black@0,crop={0}:{1}", outWidth.Value, outHeight.Value); } @@ -3340,9 +3335,8 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasGraphicalSubs) { - // [0:s]scale=s=1280x720 - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } @@ -3504,9 +3498,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - // scale=s=1280x720,format=yuva420p,hwupload - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); subFilters.Add("format=yuva420p"); } else if (hasTextSubs) @@ -3527,8 +3520,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } } @@ -3702,9 +3695,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - // scale=s=1280x720,format=yuva420p,hwupload - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); subFilters.Add("format=yuva420p"); } else if (hasTextSubs) @@ -3727,8 +3719,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } } @@ -3938,10 +3930,9 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - // scale,format=bgra,hwupload - // overlay_qsv can handle overlay scaling, - // add a dummy scale filter to pair with -canvas_size. - subFilters.Add("scale=flags=fast_bilinear"); + // overlay_qsv can handle overlay scaling, setup a smaller height to reduce transfer overhead + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, 1080); + subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } else if (hasTextSubs) @@ -3973,8 +3964,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } } @@ -4158,7 +4149,9 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - subFilters.Add("scale=flags=fast_bilinear"); + // overlay_qsv can handle overlay scaling, setup a smaller height to reduce transfer overhead + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, 1080); + subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } else if (hasTextSubs) @@ -4189,8 +4182,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } } @@ -4425,7 +4418,9 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - subFilters.Add("scale=flags=fast_bilinear"); + // overlay_vaapi can handle overlay scaling, setup a smaller height to reduce transfer overhead + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, 1080); + subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } else if (hasTextSubs) @@ -4454,8 +4449,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); if (isVaapiEncoder) @@ -4599,9 +4594,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - // scale=s=1280x720,format=bgra,hwupload - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } else if (hasTextSubs) @@ -4815,8 +4809,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); if (isVaapiEncoder) diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 629c30060..b532f9a7e 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -742,6 +742,10 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); + // Graphical subtitle may have width and height info + stream.Width = streamInfo.Width; + stream.Height = streamInfo.Height; + if (string.IsNullOrEmpty(stream.Title)) { // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler" -- cgit v1.2.3 From 92c0ec0c1bc6c25d2dd9e531fcc26a13883bea8a Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Sun, 28 Jan 2024 19:29:23 +0800 Subject: Use video framerate for ASS subtitle HW burn-in Signed-off-by: nyanmisaka --- .../MediaEncoding/EncodingHelper.cs | 34 +++++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index cffc014e8..2a2614e4d 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2908,7 +2908,7 @@ namespace MediaBrowser.Controller.MediaEncoding int? requestedHeight, int? requestedMaxWidth, int? requestedMaxHeight, - int? framerate) + float? framerate) { var reqTicks = state.BaseRequest.StartTimeTicks ?? 0; var startTime = TimeSpan.FromTicks(reqTicks).ToString(@"hh\\\:mm\\\:ss\\\.fff", CultureInfo.InvariantCulture); @@ -2927,7 +2927,7 @@ namespace MediaBrowser.Controller.MediaEncoding "alphasrc=s={0}x{1}:r={2}:start='{3}'", outWidth.Value, outHeight.Value, - framerate ?? 10, + framerate ?? 25, reqTicks > 0 ? startTime : 0); } @@ -3504,8 +3504,11 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasTextSubs) { + var framerate = state.VideoStream?.RealFrameRate; + var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10; + // alphasrc=s=1280x720:r=10:start=0,format=yuva420p,subtitles,hwupload - var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, subFramerate); var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); subFilters.Add(alphaSrcFilter); subFilters.Add("format=yuva420p"); @@ -3701,8 +3704,11 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasTextSubs) { + var framerate = state.VideoStream?.RealFrameRate; + var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10; + // alphasrc=s=1280x720:r=10:start=0,format=yuva420p,subtitles,hwupload - var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, subFramerate); var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); subFilters.Add(alphaSrcFilter); subFilters.Add("format=yuva420p"); @@ -3937,8 +3943,11 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasTextSubs) { + var framerate = state.VideoStream?.RealFrameRate; + var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10; + // alphasrc=s=1280x720:r=10:start=0,format=bgra,subtitles,hwupload - var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5); + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, subFramerate); var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); subFilters.Add(alphaSrcFilter); subFilters.Add("format=bgra"); @@ -4156,7 +4165,10 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasTextSubs) { - var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5); + var framerate = state.VideoStream?.RealFrameRate; + var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10; + + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, subFramerate); var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); subFilters.Add(alphaSrcFilter); subFilters.Add("format=bgra"); @@ -4425,7 +4437,10 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasTextSubs) { - var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5); + var framerate = state.VideoStream?.RealFrameRate; + var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10; + + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, subFramerate); var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); subFilters.Add(alphaSrcFilter); subFilters.Add("format=bgra"); @@ -4600,7 +4615,10 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasTextSubs) { - var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); + var framerate = state.VideoStream?.RealFrameRate; + var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10; + + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, subFramerate); var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); subFilters.Add(alphaSrcFilter); subFilters.Add("format=bgra"); -- cgit v1.2.3 From b943d629a1b07315ba7f6d2fe31b2610692c503d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Jan 2024 13:55:01 +0000 Subject: chore(deps): update dependency svg.skia to v1.0.0.13 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index dcf183494..c88b9e808 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -71,7 +71,7 @@ - + -- cgit v1.2.3 From d9b911ce7f401d325530335038ef494c45660faa Mon Sep 17 00:00:00 2001 From: azam Date: Sun, 28 Jan 2024 05:53:28 +0000 Subject: Translated using Weblate (Malay) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ms/ --- Emby.Server.Implementations/Localization/Core/ms.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index a07222975..ebd3f7560 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -124,5 +124,7 @@ "External": "Luaran", "TaskOptimizeDatabase": "Optimumkan pangkalan data", "TaskKeyframeExtractor": "Ekstrak bingkai kunci", - "TaskKeyframeExtractorDescription": "Ekstrak bingkai kunci dari fail video untuk membina HLS playlist yang lebih tepat. Tugas ini mungkin perlukan masa yang panjang." + "TaskKeyframeExtractorDescription": "Ekstrak bingkai kunci dari fail video untuk membina HLS playlist yang lebih tepat. Tugas ini mungkin perlukan masa yang panjang.", + "TaskRefreshTrickplayImagesDescription": "Jana gambar prebiu Trickplay untuk video dalam perpustakaan.", + "TaskRefreshTrickplayImages": "Jana gambar Trickplay" } -- cgit v1.2.3 From 2b03927e0e9e9632af3384f237c34156ec4b7144 Mon Sep 17 00:00:00 2001 From: hoanghuy309 Date: Fri, 26 Jan 2024 03:02:35 +0000 Subject: Translated using Weblate (Vietnamese) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/vi/ --- Emby.Server.Implementations/Localization/Core/vi.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 44ce4ac5b..e92752c5f 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -123,5 +123,7 @@ "TaskKeyframeExtractor": "Trích Xuất Khung Hình", "TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.", "External": "Bên ngoài", - "HearingImpaired": "Khiếm Thính" + "HearingImpaired": "Khiếm Thính", + "TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay", + "TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật." } -- cgit v1.2.3 From 73a9bd1ae59260e54f7a4531f4a7ebd83b6a764a Mon Sep 17 00:00:00 2001 From: antti202 Date: Fri, 26 Jan 2024 18:45:46 +0000 Subject: Translated using Weblate (Estonian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/ --- Emby.Server.Implementations/Localization/Core/et.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index 081462407..c78ffa28c 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -52,7 +52,7 @@ "PluginUninstalledWithName": "{0} eemaldati", "PluginInstalledWithName": "{0} paigaldati", "Plugin": "Plugin", - "Playlists": "Pleilistid", + "Playlists": "Esitusloendid", "Photos": "Fotod", "NotificationOptionVideoPlaybackStopped": "Video taasesitus lõppes", "NotificationOptionVideoPlayback": "Video taasesitus algas", @@ -123,5 +123,7 @@ "External": "Väline", "HearingImpaired": "Kuulmispuudega", "TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadreid, et luua täpsemaid HLS-i esitusloendeid. See ülesanne võib kesta pikka aega.", - "TaskKeyframeExtractor": "Võtmekaadri ekstraktor" + "TaskKeyframeExtractor": "Võtmekaadri ekstraktor", + "TaskRefreshTrickplayImages": "Loo eelvaate pildid", + "TaskRefreshTrickplayImagesDescription": "Loob eelvaated videotele, kus lubatud." } -- cgit v1.2.3 From a3fb24233c55f5980d171af846ad68e37b8666e8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 30 Jan 2024 00:17:06 +0000 Subject: chore(deps): update dependency blurhashsharp to v1.3.2 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index c88b9e808..d1298d9e5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + -- cgit v1.2.3 From fd116e76160e10585958049867f443a7879c42e2 Mon Sep 17 00:00:00 2001 From: LesDomen Date: Mon, 29 Jan 2024 10:23:01 +0000 Subject: Translated using Weblate (Slovenian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sl/ --- Emby.Server.Implementations/Localization/Core/sl-SI.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 1944e072c..110af11b7 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -124,5 +124,7 @@ "TaskKeyframeExtractor": "Ekstraktor ključnih sličic", "External": "Zunanji", "TaskKeyframeExtractorDescription": "Iz video datoteke Izvleče ključne sličice, da ustvari bolj natančne sezname predvajanja HLS. Proces lahko traja dolgo časa.", - "HearingImpaired": "Oslabljen sluh" + "HearingImpaired": "Oslabljen sluh", + "TaskRefreshTrickplayImages": "Ustvari Trickplay slike", + "TaskRefreshTrickplayImagesDescription": "Ustvari trickplay predoglede za posnetke v omogočenih knjižnicah." } -- cgit v1.2.3 From ce81e2aeab942538a7d5640b7ad88a50398b10d5 Mon Sep 17 00:00:00 2001 From: Attila Szakacs Date: Thu, 18 Jan 2024 17:00:00 +0100 Subject: Add alltilla to CONTRIBUTORS.md Signed-off-by: Attila Szakacs --- CONTRIBUTORS.md | 1 + .../Subtitles/SubtitleEncoder.cs | 34 +++++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 457f59e0f..5dcb6daa3 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -4,6 +4,7 @@ - [97carmine](https://github.com/97carmine) - [Abbe98](https://github.com/Abbe98) - [agrenott](https://github.com/agrenott) + - [alltilla](https://github.com/alltilla) - [AndreCarvalho](https://github.com/AndreCarvalho) - [anthonylavado](https://github.com/anthonylavado) - [Artiume](https://github.com/Artiume) diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 0e66565ed..8fd1f9fc1 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -472,8 +472,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// Task. private async Task ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) { - var semaphores = new List { }; - var extractableStreams = new List { }; + var semaphores = new List(); + var extractableStreams = new List(); try { @@ -498,9 +498,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles } if (extractableStreams.Count > 0) - { - await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); - } + { + await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); + } } catch (Exception ex) { @@ -521,7 +521,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles CancellationToken cancellationToken) { var inputPath = mediaSource.Path; - var outputPaths = new List { }; + var outputPaths = new List(); var args = string.Format( CultureInfo.InvariantCulture, "-i {0} -copyts", @@ -531,6 +531,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles { var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; + var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); + + if (streamIndex == -1) + { + _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index); + continue; + } Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid.")); @@ -538,7 +545,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles args += string.Format( CultureInfo.InvariantCulture, " -map 0:{0} -an -vn -c:s {1} \"{2}\"", - subtitleStream.Index, + streamIndex, outputCodec, outputPath); } @@ -614,16 +621,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles { _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); failed = true; + continue; } - else - { - if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase)) - { - await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); - } - _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); + if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase)) + { + await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); } + + _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); } } -- cgit v1.2.3 From e45a2f7e10570301a7c78ef6700cba65649468e6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 31 Jan 2024 23:14:55 +0000 Subject: chore(deps): update dependency blurhashsharp.skiasharp to v1.3.2 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d1298d9e5..8b4813f56 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + -- cgit v1.2.3 From 7d6a03bad6b8baacb83625be32e708090722fe10 Mon Sep 17 00:00:00 2001 From: TelepathicWalrus Date: Thu, 1 Feb 2024 07:14:25 +0000 Subject: Change nested try catch to using statement --- Emby.Server.Implementations/Library/LiveStreamHelper.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index d6530df2d..d4aeae41a 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -52,17 +52,11 @@ namespace Emby.Server.Implementations.Library { FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); - try + await using (jsonStream.ConfigureAwait(false)) { mediaInfo = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); } - catch (Exception ex) - { - _logger.LogError(ex, "Error deserializing mediainfo cache"); - } - - await jsonStream.DisposeAsync().ConfigureAwait(false); } catch (IOException ex) { -- cgit v1.2.3 From d423efd2eac8bb3d392b9d6c98b172cc05b64a5a Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Wed, 20 Dec 2023 13:56:28 +0800 Subject: Add a new HWA type RKMPP Signed-off-by: nyanmisaka --- MediaBrowser.Model/Session/HardwareEncodingType.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/MediaBrowser.Model/Session/HardwareEncodingType.cs b/MediaBrowser.Model/Session/HardwareEncodingType.cs index f5753467a..058875cd3 100644 --- a/MediaBrowser.Model/Session/HardwareEncodingType.cs +++ b/MediaBrowser.Model/Session/HardwareEncodingType.cs @@ -33,6 +33,11 @@ /// /// Video ToolBox. /// - VideoToolBox = 5 + VideoToolBox = 5, + + /// + /// Rockchip Media Process Platform (RKMPP). + /// + RKMPP = 6 } } -- cgit v1.2.3 From 52da00c3c7ba30d04feacb3956b4df0bbc08c76b Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Wed, 20 Dec 2023 13:58:09 +0800 Subject: Register RKMPP HW codecs and filters Signed-off-by: nyanmisaka --- .../Encoder/EncoderValidator.cs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 0d1d27ae8..fdca28390 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -45,7 +45,15 @@ namespace MediaBrowser.MediaEncoding.Encoder "mpeg4_cuvid", "vp8_cuvid", "vp9_cuvid", - "av1_cuvid" + "av1_cuvid", + "h264_rkmpp", + "hevc_rkmpp", + "mpeg1_rkmpp", + "mpeg2_rkmpp", + "mpeg4_rkmpp", + "vp8_rkmpp", + "vp9_rkmpp", + "av1_rkmpp" }; private static readonly string[] _requiredEncoders = new[] @@ -82,7 +90,9 @@ namespace MediaBrowser.MediaEncoding.Encoder "av1_vaapi", "h264_v4l2m2m", "h264_videotoolbox", - "hevc_videotoolbox" + "hevc_videotoolbox", + "h264_rkmpp", + "hevc_rkmpp" }; private static readonly string[] _requiredFilters = new[] @@ -116,9 +126,12 @@ namespace MediaBrowser.MediaEncoding.Encoder "libplacebo", "scale_vulkan", "overlay_vulkan", - "hwupload_vaapi", // videotoolbox - "yadif_videotoolbox" + "yadif_videotoolbox", + // rkrga + "scale_rkrga", + "vpp_rkrga", + "overlay_rkrga" }; private static readonly Dictionary _filterOptionsDict = new Dictionary -- cgit v1.2.3 From e62dab627e7eab650d594ca9ca9236e504863bbe Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Mon, 29 Jan 2024 19:46:17 +0800 Subject: Add full HWA transcoding pipeline for RKMPP Signed-off-by: nyanmisaka --- .../MediaEncoding/EncodingHelper.cs | 464 ++++++++++++++++++++- 1 file changed, 459 insertions(+), 5 deletions(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 2a2614e4d..1c95192f1 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -30,6 +30,7 @@ namespace MediaBrowser.Controller.MediaEncoding private const string VaapiAlias = "va"; private const string D3d11vaAlias = "dx11"; private const string VideotoolboxAlias = "vt"; + private const string RkmppAlias = "rk"; private const string OpenclAlias = "ocl"; private const string CudaAlias = "cu"; private const string DrmAlias = "dr"; @@ -161,6 +162,7 @@ namespace MediaBrowser.Controller.MediaEncoding { "vaapi", hwEncoder + "_vaapi" }, { "videotoolbox", hwEncoder + "_videotoolbox" }, { "v4l2m2m", hwEncoder + "_v4l2m2m" }, + { "rkmpp", hwEncoder + "_rkmpp" }, }; if (!string.IsNullOrEmpty(hwType) @@ -217,6 +219,14 @@ namespace MediaBrowser.Controller.MediaEncoding && _mediaEncoder.SupportsFilter("hwupload_vaapi"); } + private bool IsRkmppFullSupported() + { + return _mediaEncoder.SupportsHwaccel("rkmpp") + && _mediaEncoder.SupportsFilter("scale_rkrga") + && _mediaEncoder.SupportsFilter("vpp_rkrga") + && _mediaEncoder.SupportsFilter("overlay_rkrga"); + } + private bool IsOpenclFullSupported() { return _mediaEncoder.SupportsHwaccel("opencl") @@ -696,6 +706,14 @@ namespace MediaBrowser.Controller.MediaEncoding return codec.ToLowerInvariant(); } + private string GetRkmppDeviceArgs(string alias) + { + alias ??= RkmppAlias; + + // device selection in rk is not supported. + return " -init_hw_device rkmpp=" + alias; + } + private string GetVideoToolboxDeviceArgs(string alias) { alias ??= VideotoolboxAlias; @@ -1056,6 +1074,33 @@ namespace MediaBrowser.Controller.MediaEncoding // no videotoolbox hw filter. args.Append(GetVideoToolboxDeviceArgs(VideotoolboxAlias)); } + else if (string.Equals(optHwaccelType, "rkmpp", StringComparison.OrdinalIgnoreCase)) + { + if (!isLinux || !_mediaEncoder.SupportsHwaccel("rkmpp")) + { + return string.Empty; + } + + var isRkmppDecoder = vidDecoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase); + var isRkmppEncoder = vidEncoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase); + if (!isRkmppDecoder && !isRkmppEncoder) + { + return string.Empty; + } + + args.Append(GetRkmppDeviceArgs(RkmppAlias)); + + var filterDevArgs = string.Empty; + var doOclTonemap = isHwTonemapAvailable && IsOpenclFullSupported(); + + if (doOclTonemap && !isRkmppDecoder) + { + args.Append(GetOpenclDeviceArgs(0, null, RkmppAlias, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + } + + args.Append(filterDevArgs); + } if (!string.IsNullOrEmpty(vidDecoder)) { @@ -1472,8 +1517,10 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_rkmpp", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_rkmpp", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "av1_qsv", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "av1_nvenc", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "av1_amf", StringComparison.OrdinalIgnoreCase) @@ -1913,20 +1960,22 @@ namespace MediaBrowser.Controller.MediaEncoding profile = "constrained_baseline"; } - // libx264, h264_qsv and h264_nvenc does not support Constrained Baseline profile, force Baseline in this case. + // libx264, h264_{qsv,nvenc,rkmpp} does not support Constrained Baseline profile, force Baseline in this case. if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_rkmpp", StringComparison.OrdinalIgnoreCase)) && profile.Contains("baseline", StringComparison.OrdinalIgnoreCase)) { profile = "baseline"; } - // libx264, h264_qsv, h264_nvenc and h264_vaapi does not support Constrained High profile, force High in this case. + // libx264, h264_{qsv,nvenc,vaapi,rkmpp} does not support Constrained High profile, force High in this case. if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_rkmpp", StringComparison.OrdinalIgnoreCase)) && profile.Contains("high", StringComparison.OrdinalIgnoreCase)) { profile = "high"; @@ -2010,6 +2059,11 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -level " + level; } } + else if (string.Equals(videoEncoder, "h264_rkmpp", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_rkmpp", StringComparison.OrdinalIgnoreCase)) + { + param += " -level " + level; + } else if (!string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) { param += " -level " + level; @@ -2828,6 +2882,48 @@ namespace MediaBrowser.Controller.MediaEncoding return (outputWidth, outputHeight); } + public static bool IsScaleRatioSupported( + int? videoWidth, + int? videoHeight, + int? requestedWidth, + int? requestedHeight, + int? requestedMaxWidth, + int? requestedMaxHeight, + double? maxScaleRatio) + { + var (outWidth, outHeight) = GetFixedOutputSize( + videoWidth, + videoHeight, + requestedWidth, + requestedHeight, + requestedMaxWidth, + requestedMaxHeight); + + if (!videoWidth.HasValue + || !videoHeight.HasValue + || !outWidth.HasValue + || !outHeight.HasValue + || !maxScaleRatio.HasValue + || (maxScaleRatio.Value < 1.0f)) + { + return false; + } + + var minScaleRatio = 1.0f / maxScaleRatio; + var scaleRatioW = (double)outWidth / (double)videoWidth; + var scaleRatioH = (double)outHeight / (double)videoHeight; + + if (scaleRatioW < minScaleRatio + || scaleRatioW > maxScaleRatio + || scaleRatioH < minScaleRatio + || scaleRatioH > maxScaleRatio) + { + return false; + } + + return true; + } + public static string GetHwScaleFilter( string hwScaleSuffix, string videoFormat, @@ -4910,6 +5006,237 @@ namespace MediaBrowser.Controller.MediaEncoding return (newfilters, swFilterChain.SubFilters, swFilterChain.OverlayFilters); } + /// + /// Gets the parameter of Rockchip RKMPP/RKRGA filter chain. + /// + /// Encoding state. + /// Encoding options. + /// Video encoder to use. + /// The tuple contains three lists: main, sub and overlay filters. + public (List MainFilters, List SubFilters, List OverlayFilters) GetRkmppVidFilterChain( + EncodingJobInfo state, + EncodingOptions options, + string vidEncoder) + { + if (!string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)) + { + return (null, null, null); + } + + var isLinux = OperatingSystem.IsLinux(); + var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !vidEncoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase); + var isRkmppOclSupported = isLinux && IsRkmppFullSupported() && IsOpenclFullSupported(); + + if ((isSwDecoder && isSwEncoder) + || !isRkmppOclSupported + || !_mediaEncoder.SupportsFilter("alphasrc")) + { + return GetSwVidFilterChain(state, options, vidEncoder); + } + + // prefered rkmpp + rkrga + opencl filters pipeline + if (isRkmppOclSupported) + { + return GetRkmppVidFiltersPrefered(state, options, vidDecoder, vidEncoder); + } + + return (null, null, null); + } + + public (List MainFilters, List SubFilters, List OverlayFilters) GetRkmppVidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isRkmppDecoder = vidDecoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase); + var isRkmppEncoder = vidEncoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase); + var isSwDecoder = !isRkmppDecoder; + var isSwEncoder = !isRkmppEncoder; + var isDrmInDrmOut = isRkmppDecoder && isRkmppEncoder; + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doDeintH2645 = doDeintH264 || doDeintHevc; + var doOclTonemap = IsHwTonemapAvailable(state, options); + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + var hasAssSubs = hasSubs + && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + + /* Make main filters for video stream */ + var mainFilters = new List(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doOclTonemap)); + + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + var outFormat = doOclTonemap ? "yuv420p10le" : (hasGraphicalSubs ? "yuv420p" : "nv12"); + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + if (!string.IsNullOrEmpty(swScaleFilter)) + { + swScaleFilter += ":flags=fast_bilinear"; + } + + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // keep video at memory except ocl tonemap, + // since the overhead caused by hwupload >>> using sw filter. + // sw => hw + if (doOclTonemap) + { + mainFilters.Add("hwupload=derive_device=opencl"); + } + } + else if (isRkmppDecoder) + { + // INPUT rkmpp/drm surface(gem/dma-heap) + + var isFullAfbcPipeline = isDrmInDrmOut && !doOclTonemap; + var outFormat = doOclTonemap ? "p010" : "nv12"; + var hwScaleFilter = GetHwScaleFilter("rkrga", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var hwScaleFilter2 = GetHwScaleFilter("rkrga", string.Empty, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + if (!hasSubs + || !isFullAfbcPipeline + || !string.IsNullOrEmpty(hwScaleFilter2)) + { + // try enabling AFBC to save DDR bandwidth + if (!string.IsNullOrEmpty(hwScaleFilter) && isFullAfbcPipeline) + { + hwScaleFilter += ":afbc=1"; + } + + // hw scale + mainFilters.Add(hwScaleFilter); + } + } + + if (doOclTonemap && isRkmppDecoder) + { + // map from rkmpp/drm to opencl via drm-opencl interop. + mainFilters.Add("hwmap=derive_device=opencl:mode=read"); + } + + // ocl tonemap + if (doOclTonemap) + { + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + // enable tradeoffs for performance + if (!string.IsNullOrEmpty(tonemapFilter)) + { + tonemapFilter += ":tradeoff=1"; + } + + mainFilters.Add(tonemapFilter); + } + + var memoryOutput = false; + var isUploadForOclTonemap = isSwDecoder && doOclTonemap; + if ((isRkmppDecoder && isSwEncoder) || isUploadForOclTonemap) + { + memoryOutput = true; + + // OUTPUT nv12 surface(memory) + mainFilters.Add("hwdownload"); + mainFilters.Add("format=nv12"); + } + + // OUTPUT nv12 surface(memory) + if (isSwDecoder && isRkmppEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + if (isDrmInDrmOut) + { + if (doOclTonemap) + { + // OUTPUT drm(nv12) surface(gem/dma-heap) + // reverse-mapping via drm-opencl interop. + mainFilters.Add("hwmap=derive_device=rkmpp:mode=write:reverse=1"); + mainFilters.Add("format=drm_prime"); + } + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List(); + var overlayFilters = new List(); + if (isDrmInDrmOut) + { + if (hasSubs) + { + if (hasGraphicalSubs) + { + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); + subFilters.Add("format=bgra"); + } + else if (hasTextSubs) + { + var framerate = state.VideoStream?.RealFrameRate; + var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10; + + // alphasrc=s=1280x720:r=10:start=0,format=bgra,subtitles,hwupload + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, subFramerate); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=bgra"); + subFilters.Add(subTextSubtitlesFilter); + } + + subFilters.Add("hwupload=derive_device=rkmpp"); + + // try enabling AFBC to save DDR bandwidth + overlayFilters.Add("overlay_rkrga=eof_action=pass:repeatlast=0:format=nv12:afbc=1"); + } + } + else if (memoryOutput) + { + if (hasGraphicalSubs) + { + var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subPreProcFilters); + overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); + } + } + + return (mainFilters, subFilters, overlayFilters); + } + /// /// Gets the parameter of video processing filters. /// @@ -4956,6 +5283,10 @@ namespace MediaBrowser.Controller.MediaEncoding { (mainFilters, subFilters, overlayFilters) = GetAppleVidFilterChain(state, options, outputVideoCodec); } + else if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)) + { + (mainFilters, subFilters, overlayFilters) = GetRkmppVidFilterChain(state, options, outputVideoCodec); + } else { (mainFilters, subFilters, overlayFilters) = GetSwVidFilterChain(state, options, outputVideoCodec); @@ -5087,18 +5418,21 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(videoStream.PixelFormat, "yuv420p", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream.PixelFormat, "yuvj420p", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv422p", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream.PixelFormat, "yuv444p", StringComparison.OrdinalIgnoreCase)) { return 8; } if (string.Equals(videoStream.PixelFormat, "yuv420p10le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv422p10le", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream.PixelFormat, "yuv444p10le", StringComparison.OrdinalIgnoreCase)) { return 10; } if (string.Equals(videoStream.PixelFormat, "yuv420p12le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv422p12le", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream.PixelFormat, "yuv444p12le", StringComparison.OrdinalIgnoreCase)) { return 12; @@ -5151,7 +5485,12 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))) { - return null; + // One exception is that RKMPP decoder can handle H.264 High 10. + if (!(string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase) + && string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase))) + { + return null; + } } if (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) @@ -5178,6 +5517,11 @@ namespace MediaBrowser.Controller.MediaEncoding { return GetVideotoolboxVidDecoder(state, options, videoStream, bitDepth); } + + if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)) + { + return GetRkmppVidDecoder(state, options, videoStream, bitDepth); + } } var whichCodec = videoStream.Codec; @@ -5243,6 +5587,11 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } + if (string.Equals(decoderSuffix, "rkmpp", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + return isCodecAvailable ? (" -c:v " + decoderName) : null; } @@ -5265,6 +5614,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isCudaSupported = (isLinux || isWindows) && IsCudaFullSupported(); var isQsvSupported = (isLinux || isWindows) && _mediaEncoder.SupportsHwaccel("qsv"); var isVideotoolboxSupported = isMacOS && _mediaEncoder.SupportsHwaccel("videotoolbox"); + var isRkmppSupported = isLinux && IsRkmppFullSupported(); var isCodecAvailable = options.HardwareDecodingCodecs.Contains(videoCodec, StringComparison.OrdinalIgnoreCase); var ffmpegVersion = _mediaEncoder.EncoderVersion; @@ -5367,6 +5717,14 @@ namespace MediaBrowser.Controller.MediaEncoding return " -hwaccel videotoolbox" + (outputHwSurface ? " -hwaccel_output_format videotoolbox_vld" : string.Empty); } + // Rockchip rkmpp + if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase) + && isRkmppSupported + && isCodecAvailable) + { + return " -hwaccel rkmpp" + (outputHwSurface ? " -hwaccel_output_format drm_prime" : string.Empty); + } + return null; } @@ -5673,6 +6031,102 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } + public string GetRkmppVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) + { + var isLinux = OperatingSystem.IsLinux(); + + if (!isLinux + || !string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + + // rkrga RGA2e supports range from 1/16 to 16 + if (!IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 16.0f)) + { + return null; + } + + var isRkmppOclSupported = IsRkmppFullSupported() && IsOpenclFullSupported(); + var hwSurface = isRkmppOclSupported + && _mediaEncoder.SupportsFilter("alphasrc"); + + // rkrga RGA3 supports range from 1/8 to 8 + var isAfbcSupported = hwSurface && IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f); + + // TODO: add more 8/10bit and 4:2:2 formats for Rkmpp after finishing the ffcheck tool + var is8bitSwFormatsRkmpp = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuvj420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is10bitSwFormatsRkmpp = string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10bitSwFormatsRkmpp = is8bitSwFormatsRkmpp || is10bitSwFormatsRkmpp; + + // nv15 and nv20 are bit-stream only formats + if (is10bitSwFormatsRkmpp && !hwSurface) + { + return null; + } + + if (is8bitSwFormatsRkmpp) + { + if (string.Equals(videoStream.Codec, "mpeg1video", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg1video", bitDepth, hwSurface); + } + + if (string.Equals(videoStream.Codec, "mpeg2video", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg2video", bitDepth, hwSurface); + } + + if (string.Equals(videoStream.Codec, "mpeg4", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg4", bitDepth, hwSurface); + } + + if (string.Equals(videoStream.Codec, "vp8", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp8", bitDepth, hwSurface); + } + } + + if (is8_10bitSwFormatsRkmpp) + { + if (string.Equals(videoStream.Codec, "avc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)) + { + var accelType = GetHwaccelType(state, options, "h264", bitDepth, hwSurface); + return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty); + } + + if (string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase)) + { + var accelType = GetHwaccelType(state, options, "hevc", bitDepth, hwSurface); + return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty); + } + + if (string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase)) + { + var accelType = GetHwaccelType(state, options, "vp9", bitDepth, hwSurface); + return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty); + } + + if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "av1", bitDepth, hwSurface); + } + } + + return null; + } + /// /// Gets the number of threads. /// -- cgit v1.2.3 From 7f14c14bf60683676ad4991066a9f8931a994376 Mon Sep 17 00:00:00 2001 From: felix920506 Date: Thu, 1 Feb 2024 20:29:06 -0500 Subject: Update issue report.yml --- .github/ISSUE_TEMPLATE/issue report.yml | 41 +++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 587802833..da49a7d4b 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -6,7 +6,10 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to fill out this bug report! Please provide as much detail as necessary, most questions may not be applicable to you. If you need real-time help, join us on [Matrix](https://matrix.to/#/#jellyfin-troubleshooting:matrix.org) or [Discord](https://discord.gg/zHBxVSXdBV). + Thanks for taking the time to report an issue. Before submitting a report, please do the following: + 1. Please head to our forum or chat rooms and troubleshoot with volunteers if you haven't already. Links can be found here: https://jellyfin.org/contact/ + 2. Please search the bug tracker for similar issues. If you do find one, please comment there instead of opening a new bug report. + 3. If you decide to open a new report, please provide as much detail as possible. - type: textarea id: what-happened attributes: @@ -16,12 +19,17 @@ body: The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful. This is my issue. - - Steps to Reproduce - 1. In this environment... - 2. With this config... - 3. Run '...' - 4. See error... + validations: + required: true + - type: textarea + id: repro-steps + attributes: + label: Reproduction Steps + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... validations: required: true - type: dropdown @@ -30,11 +38,10 @@ body: label: Jellyfin Version description: What version of Jellyfin are you running? options: - - 10.8.z - - 10.8.9 - - 10.7.7 - - 10.6.4 - - Other + - 10.8.13 + - 10.8.12 + - 10.8.11 or older (please specify) + - Unstable (master branch) validations: required: true - type: input @@ -77,6 +84,16 @@ body: - Networking: - Storage: render: markdown + - type: markdown + attributes: + value: | + When providing logs, please keep the following things in mind. + 1. **DO NOT** use external paste services. + 2. Please provide complete logs. + - For server logs, include everything you think is important plus *10 lines before and after* + - For ffmpeg logs, please provide the entire file unmodified. + 3. Please do not run logs through any translation program. Especially beware if your browser translates pages by default. + 4. Please do not include logs as screenshots, with the only exception being client logs in browsers. - type: textarea id: logs attributes: -- cgit v1.2.3 From 5a66741963a00588cd44a299e68113d70077074f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 2 Feb 2024 20:19:22 +0000 Subject: chore(deps): update github/codeql-action action to v3.24.0 --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index e92a404d2..839bebb96 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 + uses: github/codeql-action/init@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 + uses: github/codeql-action/autobuild@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 + uses: github/codeql-action/analyze@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 -- 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(-) 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(-) 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(-) 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 e4f715bbee26985ae7666e4c80282ac52e7fce73 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sat, 3 Feb 2024 00:26:49 -0500 Subject: Added translation using Weblate (Kyrgyz) --- Emby.Server.Implementations/Localization/Core/ky.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 Emby.Server.Implementations/Localization/Core/ky.json diff --git a/Emby.Server.Implementations/Localization/Core/ky.json b/Emby.Server.Implementations/Localization/Core/ky.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ky.json @@ -0,0 +1 @@ +{} -- cgit v1.2.3 From dba043ef488310ab9db9ff7b29aedbfb790e3daa Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 3 Feb 2024 08:52:26 +0100 Subject: Fixed merge problem --- Directory.Packages.props | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7c0169c6e..3c9c5d9c1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,21 +9,21 @@ - - + + - + - + - + @@ -58,7 +58,7 @@ - + @@ -72,7 +72,7 @@ - + @@ -86,6 +86,6 @@ - + - \ No newline at end of file + -- cgit v1.2.3 From a32fe89dad7f0d654ab5846bef6f9a52385cda1a Mon Sep 17 00:00:00 2001 From: Jake Date: Sat, 3 Feb 2024 08:25:38 -0700 Subject: Update README to include default web client urls (#10949) * Update README to include default web client url * Update README to include default web client and swagger doc urls --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 878e335ed..ec065f260 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,13 @@ cd Jellyfin.Server/bin/Debug/net8.0 # Change into the build output directory 2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`. +#### Accessing the Hosted Web Client + +If the Server is configured to host the Web Client, and the Server is running, the Web Client can be accessed at `http://localhost:8096` by default. + +API documentation can be viewed at `http://localhost:8096/api-docs/swagger/index.html` + + ### Running from GH-Codespaces As Jellyfin will run on a container on a github hosted server, JF needs to handle some things differently. -- cgit v1.2.3 From 918b627472058c5c7456dfade442817265dabb8c Mon Sep 17 00:00:00 2001 From: beakerandjake Date: Sat, 3 Feb 2024 14:25:44 -0700 Subject: Return 404 if log file does not exist --- Jellyfin.Api/Controllers/SystemController.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 3d4df0386..6c5ce4715 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -188,16 +188,24 @@ public class SystemController : BaseJellyfinApiController /// The name of the log file to get. /// Log file retrieved. /// User does not have permission to get log files. + /// Could not find a log file with the name. /// The log file. [HttpGet("Logs/Log")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesFile(MediaTypeNames.Text.Plain)] public ActionResult GetLogFile([FromQuery, Required] string name) { - var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + var file = _fileSystem + .GetFiles(_appPaths.LogDirectoryPath) + .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + + if (file is null) + { + return NotFound("Log file not found."); + } // For older files, assume fully static var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; -- cgit v1.2.3 From b4d11c8d8959580316d8fb5fa2e3f8e358769ccb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 3 Feb 2024 22:26:42 +0000 Subject: chore(deps): update danielpalme/reportgenerator-github-action action to v5.2.1 --- .github/workflows/ci-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 0dacbc5c6..4b5db14ae 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -34,7 +34,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@4d510cbed8a05af5aefea46c7fd6e05b95844c89 # 5.2.0 + uses: danielpalme/ReportGenerator-GitHub-Action@68f1963d9876d2ac78bfd1c41c395514b7318855 # 5.2.1 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" -- cgit v1.2.3 From d82d025b248cbd8eee8372dfc08940d53675d4ee Mon Sep 17 00:00:00 2001 From: beakerandjake Date: Sat, 3 Feb 2024 19:10:08 -0700 Subject: Add unit test for log file not found --- .../Controllers/SystemControllerTests.cs | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs diff --git a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs new file mode 100644 index 000000000..dd84c1a18 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs @@ -0,0 +1,35 @@ +using Jellyfin.Api.Controllers; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers +{ + public class SystemControllerTests + { + [Fact] + public void GetLogFile_FileDoesNotExist_ReturnsNotFound() + { + var mockFileSystem = new Mock(); + mockFileSystem + .Setup(fs => fs.GetFiles(It.IsAny(), It.IsAny())) + .Returns([new() { Name = "file1.txt" }, new() { Name = "file2.txt" }]); + + var controller = new SystemController( + Mock.Of>(), + Mock.Of(), + Mock.Of(), + mockFileSystem.Object, + Mock.Of(), + Mock.Of()); + + var result = controller.GetLogFile("DOES_NOT_EXIST.txt"); + + Assert.IsType(result); + } + } +} -- cgit v1.2.3 From a1667230746e4f301728f88698ddedf498251fbc Mon Sep 17 00:00:00 2001 From: felix920506 Date: Mon, 5 Feb 2024 01:13:06 -0500 Subject: Update issue report.yml --- .github/ISSUE_TEMPLATE/issue report.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index da49a7d4b..b690b82c2 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -10,6 +10,7 @@ body: 1. Please head to our forum or chat rooms and troubleshoot with volunteers if you haven't already. Links can be found here: https://jellyfin.org/contact/ 2. Please search the bug tracker for similar issues. If you do find one, please comment there instead of opening a new bug report. 3. If you decide to open a new report, please provide as much detail as possible. + 4. Please **ONLY** report **ONE** issue per report. If you are experiencing multiple issues, please open multiple reports. - type: textarea id: what-happened attributes: @@ -17,8 +18,7 @@ body: description: Also tell us, what did you expect to happen? placeholder: | The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful. - - This is my issue. + If you are using an old release of Jellyfin, please also explain why. validations: required: true - type: textarea @@ -84,6 +84,8 @@ body: - Networking: - Storage: render: markdown + validations: + required: true - type: markdown attributes: value: | @@ -101,6 +103,8 @@ body: description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs. placeholder: For playback issues, browser/client and FFmpeg logs may be more useful. render: shell + validations: + required: true - type: textarea id: ffmpeg-logs attributes: -- cgit v1.2.3 From be265cd87f2d65c99c6a1d7d128dc4391724939e Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Mon, 5 Feb 2024 23:41:43 +0800 Subject: Add EqualsAny for VideoCodecTag condition Signed-off-by: nyanmisaka --- .../MediaEncoding/BaseEncodingJobOptions.cs | 6 ++++ .../MediaEncoding/EncodingJobInfo.cs | 20 ++++++++++++++ MediaBrowser.Model/Dlna/StreamBuilder.cs | 32 ++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index fb4e7bd1f..29dd190ab 100644 --- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -87,6 +87,12 @@ namespace MediaBrowser.Controller.MediaEncoding /// The level. public string Level { get; set; } + /// + /// Gets or sets the codec tag. + /// + /// The codec tag. + public string CodecTag { get; set; } + /// /// Gets or sets the framerate. /// diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 17813559a..f2a0b906d 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -619,6 +619,26 @@ namespace MediaBrowser.Controller.MediaEncoding return Array.Empty(); } + public string[] GetRequestedCodecTags(string codec) + { + if (!string.IsNullOrEmpty(BaseRequest.CodecTag)) + { + return BaseRequest.CodecTag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + } + + if (!string.IsNullOrEmpty(codec)) + { + var codectag = BaseRequest.GetOption(codec, "codectag"); + + if (!string.IsNullOrEmpty(codectag)) + { + return codectag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + } + } + + return Array.Empty(); + } + public string GetRequestedLevel(string codec) { if (!string.IsNullOrEmpty(BaseRequest.Level)) diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index da683a17e..e6b7f4d9b 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -1944,6 +1944,38 @@ namespace MediaBrowser.Model.Dlna break; } + case ProfileConditionValue.VideoCodecTag: + { + if (string.IsNullOrEmpty(qualifier)) + { + continue; + } + + // change from split by | to comma + // strip spaces to avoid having to encode + var values = value + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (condition.Condition == ProfileConditionType.Equals) + { + item.SetOption(qualifier, "codectag", string.Join(',', values)); + } + else if (condition.Condition == ProfileConditionType.EqualsAny) + { + var currentValue = item.GetOption(qualifier, "codectag"); + if (!string.IsNullOrEmpty(currentValue) && values.Any(v => string.Equals(v, currentValue, StringComparison.OrdinalIgnoreCase))) + { + item.SetOption(qualifier, "codectag", currentValue); + } + else + { + item.SetOption(qualifier, "codectag", string.Join(',', values)); + } + } + + break; + } + case ProfileConditionValue.Height: { if (!enableNonQualifiedConditions) -- cgit v1.2.3 From ff467e3309d03120e489e0467b2ddcfb71ad3a07 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 22:58:13 +0000 Subject: chore(deps): update ci dependencies --- .github/workflows/ci-openapi.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 17fe0400a..97f1a33e7 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -25,7 +25,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 with: name: openapi-head retention-days: 14 @@ -59,7 +59,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 with: name: openapi-base retention-days: 14 @@ -78,12 +78,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 with: name: openapi-base path: openapi-base -- cgit v1.2.3 From 34a89fdefd47d4007d5222af9f71fbf7c3a7a1b8 Mon Sep 17 00:00:00 2001 From: Soumendra kumar sahoo Date: Tue, 6 Feb 2024 09:09:32 +0000 Subject: Translated using Weblate (Odia) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/or/ --- Emby.Server.Implementations/Localization/Core/or.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/or.json b/Emby.Server.Implementations/Localization/Core/or.json index 0e9d81ee8..8251c1290 100644 --- a/Emby.Server.Implementations/Localization/Core/or.json +++ b/Emby.Server.Implementations/Localization/Core/or.json @@ -1,4 +1,12 @@ { "External": "ବହିଃସ୍ଥ", - "Genres": "ଧରଣ" + "Genres": "ଧରଣ", + "Albums": "ଆଲବମଗୁଡ଼ିକ", + "Artists": "କଳାକାରଗୁଡ଼ିକ", + "Application": "ଆପ୍ଲିକେସନ", + "Books": "ବହିଗୁଡ଼ିକ", + "Channels": "ଚ୍ୟାନେଲଗୁଡ଼ିକ", + "ChapterNameValue": "ବିଭାଗ {0}", + "Collections": "ସଂଗ୍ରହଗୁଡ଼ିକ", + "Folders": "ଫୋଲ୍ଡରଗୁଡ଼ିକ" } -- cgit v1.2.3 From 5cc451992b29fd03f281bdeddce370c9ff87f82e Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Tue, 6 Feb 2024 20:36:15 +0800 Subject: Correct VIDEO-RANGE field for HLG content Signed-off-by: nyanmisaka --- Jellyfin.Api/Helpers/DynamicHlsHelper.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index fa81fc284..b0c17c835 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -325,6 +325,7 @@ public class DynamicHlsHelper if (state.VideoStream is not null && state.VideoStream.VideoRange != VideoRange.Unknown) { var videoRange = state.VideoStream.VideoRange; + var videoRangeType = state.VideoStream.VideoRangeType; if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { if (videoRange == VideoRange.SDR) @@ -334,7 +335,14 @@ public class DynamicHlsHelper if (videoRange == VideoRange.HDR) { - builder.Append(",VIDEO-RANGE=PQ"); + if (videoRangeType == VideoRangeType.HLG) + { + builder.Append(",VIDEO-RANGE=HLG"); + } + else + { + builder.Append(",VIDEO-RANGE=PQ"); + } } } else -- 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(-) 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 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 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 a54c08209e467dfafe924cc6acb691deb2daa428 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 10:06:39 -0500 Subject: Remove some unused media encoding code --- .../MediaEncoding/ImageEncodingOptions.cs | 23 ---------------------- .../MediaEncoding/MediaEncoderHelpers.cs | 11 ----------- 2 files changed, 34 deletions(-) delete mode 100644 MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs delete mode 100644 MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs diff --git a/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs b/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs deleted file mode 100644 index 044ba6d33..000000000 --- a/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -namespace MediaBrowser.Controller.MediaEncoding -{ - public class ImageEncodingOptions - { - public string InputPath { get; set; } - - public int? Width { get; set; } - - public int? Height { get; set; } - - public int? MaxWidth { get; set; } - - public int? MaxHeight { get; set; } - - public int? Quality { get; set; } - - public string Format { get; set; } - } -} diff --git a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs b/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs deleted file mode 100644 index 841e7b287..000000000 --- a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs +++ /dev/null @@ -1,11 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Controller.MediaEncoding -{ - /// - /// Class MediaEncoderHelpers. - /// - public static class MediaEncoderHelpers - { - } -} -- 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 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 6d8062116cc89a66a66352a8ed44cfad2f3a00ba Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 10:15:29 -0500 Subject: Remove some unused model code --- MediaBrowser.Model/ClientLog/ClientLogEvent.cs | 75 -------------------------- MediaBrowser.Model/Dto/ImageByNameInfo.cs | 38 ------------- MediaBrowser.Model/Entities/SpecialFolder.cs | 36 ------------- MediaBrowser.Model/Net/SocketReceiveResult.cs | 32 ----------- 4 files changed, 181 deletions(-) delete mode 100644 MediaBrowser.Model/ClientLog/ClientLogEvent.cs delete mode 100644 MediaBrowser.Model/Dto/ImageByNameInfo.cs delete mode 100644 MediaBrowser.Model/Entities/SpecialFolder.cs delete mode 100644 MediaBrowser.Model/Net/SocketReceiveResult.cs diff --git a/MediaBrowser.Model/ClientLog/ClientLogEvent.cs b/MediaBrowser.Model/ClientLog/ClientLogEvent.cs deleted file mode 100644 index 21087b564..000000000 --- a/MediaBrowser.Model/ClientLog/ClientLogEvent.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Model.ClientLog -{ - /// - /// The client log event. - /// - public class ClientLogEvent - { - /// - /// Initializes a new instance of the class. - /// - /// The log timestamp. - /// The log level. - /// The user id. - /// The client name. - /// The client version. - /// The device id. - /// The message. - public ClientLogEvent( - DateTime timestamp, - LogLevel level, - Guid? userId, - string clientName, - string clientVersion, - string deviceId, - string message) - { - Timestamp = timestamp; - UserId = userId; - ClientName = clientName; - ClientVersion = clientVersion; - DeviceId = deviceId; - Message = message; - Level = level; - } - - /// - /// Gets the event timestamp. - /// - public DateTime Timestamp { get; } - - /// - /// Gets the log level. - /// - public LogLevel Level { get; } - - /// - /// Gets the user id. - /// - public Guid? UserId { get; } - - /// - /// Gets the client name. - /// - public string ClientName { get; } - - /// - /// Gets the client version. - /// - public string ClientVersion { get; } - - /// - /// - /// Gets the device id. - /// - public string DeviceId { get; } - - /// - /// Gets the log message. - /// - public string Message { get; } - } -} diff --git a/MediaBrowser.Model/Dto/ImageByNameInfo.cs b/MediaBrowser.Model/Dto/ImageByNameInfo.cs deleted file mode 100644 index 06cc3e73c..000000000 --- a/MediaBrowser.Model/Dto/ImageByNameInfo.cs +++ /dev/null @@ -1,38 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Dto -{ - public class ImageByNameInfo - { - /// - /// Gets or sets the name. - /// - /// The name. - public string Name { get; set; } - - /// - /// Gets or sets the theme. - /// - /// The theme. - public string Theme { get; set; } - - /// - /// Gets or sets the context. - /// - /// The context. - public string Context { get; set; } - - /// - /// Gets or sets the length of the file. - /// - /// The length of the file. - public long FileLength { get; set; } - - /// - /// Gets or sets the format. - /// - /// The format. - public string Format { get; set; } - } -} diff --git a/MediaBrowser.Model/Entities/SpecialFolder.cs b/MediaBrowser.Model/Entities/SpecialFolder.cs deleted file mode 100644 index 2250c5dff..000000000 --- a/MediaBrowser.Model/Entities/SpecialFolder.cs +++ /dev/null @@ -1,36 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Entities -{ - public static class SpecialFolder - { - public const string TvShowSeries = "TvShowSeries"; - public const string TvGenres = "TvGenres"; - public const string TvGenre = "TvGenre"; - public const string TvLatest = "TvLatest"; - public const string TvNextUp = "TvNextUp"; - public const string TvResume = "TvResume"; - public const string TvFavoriteSeries = "TvFavoriteSeries"; - public const string TvFavoriteEpisodes = "TvFavoriteEpisodes"; - - public const string MovieLatest = "MovieLatest"; - public const string MovieResume = "MovieResume"; - public const string MovieMovies = "MovieMovies"; - public const string MovieCollections = "MovieCollections"; - public const string MovieFavorites = "MovieFavorites"; - public const string MovieGenres = "MovieGenres"; - public const string MovieGenre = "MovieGenre"; - - public const string MusicArtists = "MusicArtists"; - public const string MusicAlbumArtists = "MusicAlbumArtists"; - public const string MusicAlbums = "MusicAlbums"; - public const string MusicGenres = "MusicGenres"; - public const string MusicLatest = "MusicLatest"; - public const string MusicPlaylists = "MusicPlaylists"; - public const string MusicSongs = "MusicSongs"; - public const string MusicFavorites = "MusicFavorites"; - public const string MusicFavoriteArtists = "MusicFavoriteArtists"; - public const string MusicFavoriteAlbums = "MusicFavoriteAlbums"; - public const string MusicFavoriteSongs = "MusicFavoriteSongs"; - } -} diff --git a/MediaBrowser.Model/Net/SocketReceiveResult.cs b/MediaBrowser.Model/Net/SocketReceiveResult.cs deleted file mode 100644 index 1524786ea..000000000 --- a/MediaBrowser.Model/Net/SocketReceiveResult.cs +++ /dev/null @@ -1,32 +0,0 @@ -#nullable disable - -using System.Net; - -namespace MediaBrowser.Model.Net -{ - /// - /// Used by the sockets wrapper to hold raw data received from a UDP socket. - /// - public sealed class SocketReceiveResult - { - /// - /// Gets or sets the buffer to place received data into. - /// - public byte[] Buffer { get; set; } - - /// - /// Gets or sets the number of bytes received. - /// - public int ReceivedBytes { get; set; } - - /// - /// Gets or sets the the data was received from. - /// - public IPEndPoint RemoteEndPoint { get; set; } - - /// - /// Gets or sets the local . - /// - public IPAddress LocalIPAddress { get; set; } - } -} -- cgit v1.2.3 From 505c09c85b9816519c795c114e6100585b35e249 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 11:49:51 -0500 Subject: Fix tests --- MediaBrowser.Controller/Entities/Folder.cs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index e9ff1f1a5..1f13c833b 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -435,7 +435,15 @@ namespace MediaBrowser.Controller.Entities progress.Report(percent); - ProviderManager.OnRefreshProgress(folder, percent); + // TODO: this is sometimes being called after the refresh has completed. + try + { + ProviderManager.OnRefreshProgress(folder, percent); + } + catch (InvalidOperationException e) + { + Logger.LogError(e, "Error refreshing folder"); + } }); if (validChildrenNeedGeneration) @@ -467,7 +475,15 @@ namespace MediaBrowser.Controller.Entities if (recursive) { - ProviderManager.OnRefreshProgress(folder, percent); + // TODO: this is sometimes being called after the refresh has completed. + try + { + ProviderManager.OnRefreshProgress(folder, percent); + } + catch (InvalidOperationException e) + { + Logger.LogError(e, "Error refreshing folder"); + } } }); -- cgit v1.2.3 From 056712efe500c1915acb579ba6a510636c89312d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Feb 2024 18:20:29 +0000 Subject: chore(deps): update dependency microsoft.net.test.sdk to v17.9.0 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3c9c5d9c1..4a236e5b6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,7 +48,7 @@ - + -- cgit v1.2.3 From be29b4a0c4e0a525da0a8537810d11586cec9b67 Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Tue, 6 Feb 2024 22:16:41 +0100 Subject: Fix some incompatible API deprecations --- .../Models/SessionDtos/ClientCapabilitiesDto.cs | 13 ++++++++++ MediaBrowser.Model/Session/ClientCapabilities.cs | 11 ++++++++ MediaBrowser.Model/System/SystemInfo.cs | 29 ++++++---------------- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs index acd3f29e3..12ce19368 100644 --- a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs +++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json.Converters; @@ -50,6 +51,18 @@ public class ClientCapabilitiesDto /// public string? IconUrl { get; set; } +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsContentUploading { get; set; } + + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsSync { get; set; } +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + /// /// Convert the dto to the full model. /// diff --git a/MediaBrowser.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs index 597845fc1..5f51fb21c 100644 --- a/MediaBrowser.Model/Session/ClientCapabilities.cs +++ b/MediaBrowser.Model/Session/ClientCapabilities.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using Jellyfin.Data.Enums; using MediaBrowser.Model.Dlna; @@ -30,5 +31,15 @@ namespace MediaBrowser.Model.Session public string AppStoreUrl { get; set; } public string IconUrl { get; set; } + + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsContentUploading { get; set; } + + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsSync { get; set; } } } diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index aa7c03ebd..f37ac6a14 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -3,29 +3,11 @@ using System; using System.Collections.Generic; -using System.Runtime.InteropServices; +using System.ComponentModel; using MediaBrowser.Model.Updates; namespace MediaBrowser.Model.System { - /// - /// Enum describing the location of the FFmpeg tool. - /// - public enum FFmpegLocation - { - /// No path to FFmpeg found. - NotFound, - - /// Path supplied via command line using switch --ffmpeg. - SetByArgument, - - /// User has supplied path via Transcoding UI page. - Custom, - - /// FFmpeg tool found on system $PATH. - System - } - /// /// Class SystemInfo. /// @@ -83,9 +65,11 @@ namespace MediaBrowser.Model.System /// /// true. [Obsolete("This is always true")] + [DefaultValue(true)] public bool CanSelfRestart { get; set; } = true; [Obsolete("This is always false")] + [DefaultValue(false)] public bool CanLaunchWebBrowser { get; set; } = false; /// @@ -140,12 +124,15 @@ namespace MediaBrowser.Model.System /// /// true if this instance has update available; otherwise, false. [Obsolete("This should be handled by the package manager")] + [DefaultValue(false)] public bool HasUpdateAvailable { get; set; } [Obsolete("This isn't set correctly anymore")] - public FFmpegLocation EncoderLocation { get; set; } + [DefaultValue("System")] + public string EncoderLocation { get; set; } = "System"; [Obsolete("This is no longer set")] - public Architecture SystemArchitecture { get; set; } = Architecture.X64; + [DefaultValue("X64")] + public string SystemArchitecture { get; set; } = "X64"; } } -- 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 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 d986a824cde349e2e4d7e0bff34356ff364d5e74 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 15:35:13 -0500 Subject: Use IHostedService for device access management --- .../Users/DeviceAccessEntryPoint.cs | 64 ------------------ .../Users/DeviceAccessHost.cs | 76 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 64 deletions(-) delete mode 100644 Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs create mode 100644 Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs deleted file mode 100644 index a471ea1d5..000000000 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs +++ /dev/null @@ -1,64 +0,0 @@ -#pragma warning disable CS1591 - -using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using Jellyfin.Data.Queries; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Session; - -namespace Jellyfin.Server.Implementations.Users -{ - public sealed class DeviceAccessEntryPoint : IServerEntryPoint - { - private readonly IUserManager _userManager; - private readonly IDeviceManager _deviceManager; - private readonly ISessionManager _sessionManager; - - public DeviceAccessEntryPoint(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager) - { - _userManager = userManager; - _deviceManager = deviceManager; - _sessionManager = sessionManager; - } - - public Task RunAsync() - { - _userManager.OnUserUpdated += OnUserUpdated; - - return Task.CompletedTask; - } - - public void Dispose() - { - } - - private async void OnUserUpdated(object? sender, GenericEventArgs e) - { - var user = e.Argument; - if (!user.HasPermission(PermissionKind.EnableAllDevices)) - { - await UpdateDeviceAccess(user).ConfigureAwait(false); - } - } - - private async Task UpdateDeviceAccess(User user) - { - var existing = (await _deviceManager.GetDevices(new DeviceQuery - { - UserId = user.Id - }).ConfigureAwait(false)).Items; - - foreach (var device in existing) - { - if (!string.IsNullOrEmpty(device.DeviceId) && !_deviceManager.CanAccessDevice(user, device.DeviceId)) - { - await _sessionManager.Logout(device).ConfigureAwait(false); - } - } - } - } -} diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs new file mode 100644 index 000000000..e40b541a3 --- /dev/null +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs @@ -0,0 +1,76 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Hosting; + +namespace Jellyfin.Server.Implementations.Users; + +/// +/// responsible for managing user device permissions. +/// +public sealed class DeviceAccessHost : IHostedService +{ + private readonly IUserManager _userManager; + private readonly IDeviceManager _deviceManager; + private readonly ISessionManager _sessionManager; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + public DeviceAccessHost(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager) + { + _userManager = userManager; + _deviceManager = deviceManager; + _sessionManager = sessionManager; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _userManager.OnUserUpdated += OnUserUpdated; + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userManager.OnUserUpdated -= OnUserUpdated; + + return Task.CompletedTask; + } + + private async void OnUserUpdated(object? sender, GenericEventArgs e) + { + var user = e.Argument; + if (!user.HasPermission(PermissionKind.EnableAllDevices)) + { + await UpdateDeviceAccess(user).ConfigureAwait(false); + } + } + + private async Task UpdateDeviceAccess(User user) + { + var existing = (await _deviceManager.GetDevices(new DeviceQuery + { + UserId = user.Id + }).ConfigureAwait(false)).Items; + + foreach (var device in existing) + { + if (!string.IsNullOrEmpty(device.DeviceId) && !_deviceManager.CanAccessDevice(user, device.DeviceId)) + { + await _sessionManager.Logout(device).ConfigureAwait(false); + } + } + } +} -- cgit v1.2.3 From 4e02d8aa21eedc6fe9c1d3ee843db3d5e3858b4c Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 15:40:52 -0500 Subject: Convert LibraryChangedNotifier to IHostedService --- .../EntryPoints/LibraryChangedNotifier.cs | 45 +++++++++++----------- Jellyfin.Server/Startup.cs | 2 + 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 83e7b230d..4c668379c 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -13,19 +13,19 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints; /// -/// A that notifies users when libraries are updated. +/// A responsible for notifying users when libraries are updated. /// -public sealed class LibraryChangedNotifier : IServerEntryPoint +public sealed class LibraryChangedNotifier : IHostedService, IDisposable { private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; @@ -70,7 +70,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint } /// - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) { _libraryManager.ItemAdded += OnLibraryItemAdded; _libraryManager.ItemUpdated += OnLibraryItemUpdated; @@ -83,6 +83,20 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint return Task.CompletedTask; } + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _libraryManager.ItemAdded -= OnLibraryItemAdded; + _libraryManager.ItemUpdated -= OnLibraryItemUpdated; + _libraryManager.ItemRemoved -= OnLibraryItemRemoved; + + _providerManager.RefreshCompleted -= OnProviderRefreshCompleted; + _providerManager.RefreshStarted -= OnProviderRefreshStarted; + _providerManager.RefreshProgress -= OnProviderRefreshProgress; + + return Task.CompletedTask; + } + private void OnProviderRefreshProgress(object? sender, GenericEventArgs> e) { var item = e.Argument.Item1; @@ -137,9 +151,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint } private void OnProviderRefreshStarted(object? sender, GenericEventArgs e) - { - OnProviderRefreshProgress(sender, new GenericEventArgs>(new Tuple(e.Argument, 0))); - } + => OnProviderRefreshProgress(sender, new GenericEventArgs>(new Tuple(e.Argument, 0))); private void OnProviderRefreshCompleted(object? sender, GenericEventArgs e) { @@ -342,7 +354,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint return item.SourceType == SourceType.Library; } - private IEnumerable GetTopParentIds(List items, List allUserRootChildren) + private static IEnumerable GetTopParentIds(List items, List allUserRootChildren) { var list = new List(); @@ -363,7 +375,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint return list.Distinct(StringComparer.Ordinal); } - private IEnumerable TranslatePhysicalItemToUserLibrary(T item, User user, bool includeIfNotFound = false) + private T[] TranslatePhysicalItemToUserLibrary(T item, User user, bool includeIfNotFound = false) where T : BaseItem { // If the physical root changed, return the user root @@ -384,18 +396,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint /// public void Dispose() { - _libraryManager.ItemAdded -= OnLibraryItemAdded; - _libraryManager.ItemUpdated -= OnLibraryItemUpdated; - _libraryManager.ItemRemoved -= OnLibraryItemRemoved; - - _providerManager.RefreshCompleted -= OnProviderRefreshCompleted; - _providerManager.RefreshStarted -= OnProviderRefreshStarted; - _providerManager.RefreshProgress -= OnProviderRefreshProgress; - - if (_libraryUpdateTimer is not null) - { - _libraryUpdateTimer.Dispose(); - _libraryUpdateTimer = null; - } + _libraryUpdateTimer?.Dispose(); + _libraryUpdateTimer = null; } } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 7cf7d75da..bb5513f86 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; using System.Text; +using Emby.Server.Implementations.EntryPoints; using Jellyfin.Api.Middleware; using Jellyfin.LiveTv.Extensions; using Jellyfin.MediaEncoding.Hls.Extensions; @@ -126,6 +127,7 @@ namespace Jellyfin.Server services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); } /// -- cgit v1.2.3 From 9e62b6919ffc7c24b66300279c2fd3c4a0c7f5bd Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 15:54:03 -0500 Subject: Convert UserDataChangeNotifier to IHostedService --- .../EntryPoints/UserDataChangeNotifier.cs | 88 +++++++++++----------- Jellyfin.Server/Startup.cs | 1 + 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index d32759017..957ad9c01 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -8,14 +6,17 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; namespace Emby.Server.Implementations.EntryPoints { - public sealed class UserDataChangeNotifier : IServerEntryPoint + /// + /// responsible for notifying users when associated item data is updated. + /// + public sealed class UserDataChangeNotifier : IHostedService, IDisposable { private const int UpdateDuration = 500; @@ -23,25 +24,43 @@ namespace Emby.Server.Implementations.EntryPoints private readonly IUserDataManager _userDataManager; private readonly IUserManager _userManager; - private readonly Dictionary> _changedItems = new Dictionary>(); + private readonly Dictionary> _changedItems = new(); + private readonly object _syncLock = new(); - private readonly object _syncLock = new object(); private Timer? _updateTimer; - public UserDataChangeNotifier(IUserDataManager userDataManager, ISessionManager sessionManager, IUserManager userManager) + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + public UserDataChangeNotifier( + IUserDataManager userDataManager, + ISessionManager sessionManager, + IUserManager userManager) { _userDataManager = userDataManager; _sessionManager = sessionManager; _userManager = userManager; } - public Task RunAsync() + /// + public Task StartAsync(CancellationToken cancellationToken) { _userDataManager.UserDataSaved += OnUserDataManagerUserDataSaved; return Task.CompletedTask; } + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= OnUserDataManagerUserDataSaved; + + return Task.CompletedTask; + } + private void OnUserDataManagerUserDataSaved(object? sender, UserDataSaveEventArgs e) { if (e.SaveReason == UserDataSaveReason.PlaybackProgress) @@ -103,55 +122,40 @@ namespace Emby.Server.Implementations.EntryPoints } } - await SendNotifications(changes, CancellationToken.None).ConfigureAwait(false); - } - - private async Task SendNotifications(List>> changes, CancellationToken cancellationToken) - { - foreach ((var key, var value) in changes) + foreach (var (userId, changedItems) in changes) { - await SendNotifications(key, value, cancellationToken).ConfigureAwait(false); + await _sessionManager.SendMessageToUserSessions( + [userId], + SessionMessageType.UserDataChanged, + () => GetUserDataChangeInfo(userId, changedItems), + default).ConfigureAwait(false); } } - private Task SendNotifications(Guid userId, List changedItems, CancellationToken cancellationToken) - { - return _sessionManager.SendMessageToUserSessions(new List { userId }, SessionMessageType.UserDataChanged, () => GetUserDataChangeInfo(userId, changedItems), cancellationToken); - } - private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List changedItems) { var user = _userManager.GetUserById(userId); - var dtoList = changedItems - .DistinctBy(x => x.Id) - .Select(i => - { - var dto = _userDataManager.GetUserDataDto(i, user); - dto.ItemId = i.Id.ToString("N", CultureInfo.InvariantCulture); - return dto; - }) - .ToArray(); - - var userIdString = userId.ToString("N", CultureInfo.InvariantCulture); - return new UserDataChangeInfo { - UserId = userIdString, - - UserDataList = dtoList + UserId = userId.ToString("N", CultureInfo.InvariantCulture), + UserDataList = changedItems + .DistinctBy(x => x.Id) + .Select(i => + { + var dto = _userDataManager.GetUserDataDto(i, user); + dto.ItemId = i.Id.ToString("N", CultureInfo.InvariantCulture); + return dto; + }) + .ToArray() }; } + /// public void Dispose() { - if (_updateTimer is not null) - { - _updateTimer.Dispose(); - _updateTimer = null; - } - - _userDataManager.UserDataSaved -= OnUserDataManagerUserDataSaved; + _updateTimer?.Dispose(); + _updateTimer = null; } } } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index bb5513f86..b0bb182aa 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -128,6 +128,7 @@ namespace Jellyfin.Server services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); } /// -- 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(-) 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 690e603b90ae9d856386d5fb6bf3e32d4cb46a9c Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:18:17 -0500 Subject: Use IHostedService for NFO user data --- Jellyfin.Server/Startup.cs | 2 + MediaBrowser.XbmcMetadata/EntryPoint.cs | 78 ------------------------ MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs | 87 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 78 deletions(-) delete mode 100644 MediaBrowser.XbmcMetadata/EntryPoint.cs create mode 100644 MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 84f9bff61..1d78b1602 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -19,6 +19,7 @@ using Jellyfin.Server.Infrastructure; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; +using MediaBrowser.XbmcMetadata; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; @@ -128,6 +129,7 @@ namespace Jellyfin.Server services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); diff --git a/MediaBrowser.XbmcMetadata/EntryPoint.cs b/MediaBrowser.XbmcMetadata/EntryPoint.cs deleted file mode 100644 index a6216ef30..000000000 --- a/MediaBrowser.XbmcMetadata/EntryPoint.cs +++ /dev/null @@ -1,78 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.XbmcMetadata.Configuration; -using MediaBrowser.XbmcMetadata.Savers; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.XbmcMetadata -{ - public sealed class EntryPoint : IServerEntryPoint - { - private readonly IUserDataManager _userDataManager; - private readonly ILogger _logger; - private readonly IProviderManager _providerManager; - private readonly IConfigurationManager _config; - - public EntryPoint( - IUserDataManager userDataManager, - ILogger logger, - IProviderManager providerManager, - IConfigurationManager config) - { - _userDataManager = userDataManager; - _logger = logger; - _providerManager = providerManager; - _config = config; - } - - /// - public Task RunAsync() - { - _userDataManager.UserDataSaved += OnUserDataSaved; - - return Task.CompletedTask; - } - - private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) - { - if (e.SaveReason == UserDataSaveReason.PlaybackFinished || e.SaveReason == UserDataSaveReason.TogglePlayed || e.SaveReason == UserDataSaveReason.UpdateUserRating) - { - if (!string.IsNullOrWhiteSpace(_config.GetNfoConfiguration().UserId)) - { - _ = SaveMetadataForItemAsync(e.Item, ItemUpdateType.MetadataDownload); - } - } - } - - /// - public void Dispose() - { - _userDataManager.UserDataSaved -= OnUserDataSaved; - } - - private async Task SaveMetadataForItemAsync(BaseItem item, ItemUpdateType updateReason) - { - if (!item.IsFileProtocol || !item.SupportsLocalMetadata) - { - return; - } - - try - { - await _providerManager.SaveMetadataAsync(item, updateReason, new[] { BaseNfoSaver.SaverName }).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving metadata for {Path}", item.Path ?? item.Name); - } - } - } -} diff --git a/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs b/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs new file mode 100644 index 000000000..b2882194d --- /dev/null +++ b/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.XbmcMetadata.Configuration; +using MediaBrowser.XbmcMetadata.Savers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.XbmcMetadata; + +/// +/// responsible for updating NFO files' user data. +/// +public sealed class NfoUserDataSaver : IHostedService +{ + private readonly ILogger _logger; + private readonly IConfigurationManager _config; + private readonly IUserDataManager _userDataManager; + private readonly IProviderManager _providerManager; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + public NfoUserDataSaver( + ILogger logger, + IConfigurationManager config, + IUserDataManager userDataManager, + IProviderManager providerManager) + { + _logger = logger; + _config = config; + _userDataManager = userDataManager; + _providerManager = providerManager; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved += OnUserDataSaved; + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= OnUserDataSaved; + return Task.CompletedTask; + } + + private async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) + { + if (e.SaveReason is not (UserDataSaveReason.PlaybackFinished + or UserDataSaveReason.TogglePlayed or UserDataSaveReason.UpdateUserRating)) + { + return; + } + + if (string.IsNullOrWhiteSpace(_config.GetNfoConfiguration().UserId)) + { + return; + } + + var item = e.Item; + if (!item.IsFileProtocol || !item.SupportsLocalMetadata) + { + return; + } + + try + { + await _providerManager.SaveMetadataAsync(item, ItemUpdateType.MetadataDownload, [BaseNfoSaver.SaverName]) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving metadata for {Path}", item.Path ?? item.Name); + } + } +} -- 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 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 4c7eca931390f82237273b39cc26381323623180 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:35:38 -0500 Subject: Use IHostApplicationLifetime to start library monitor --- Emby.Server.Implementations/IO/LibraryMonitor.cs | 71 ++++++---------------- .../IO/LibraryMonitorStartup.cs | 35 ----------- MediaBrowser.Controller/Library/ILibraryMonitor.cs | 9 ++- 3 files changed, 24 insertions(+), 91 deletions(-) delete mode 100644 Emby.Server.Implementations/IO/LibraryMonitorStartup.cs diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index dde38906f..31617d1a5 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -11,11 +9,13 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.IO { - public class LibraryMonitor : ILibraryMonitor + /// + public sealed class LibraryMonitor : ILibraryMonitor, IDisposable { private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; @@ -25,19 +25,19 @@ namespace Emby.Server.Implementations.IO /// /// The file system watchers. /// - private readonly ConcurrentDictionary _fileSystemWatchers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _fileSystemWatchers = new(StringComparer.OrdinalIgnoreCase); /// /// The affected paths. /// - private readonly List _activeRefreshers = new List(); + private readonly List _activeRefreshers = []; /// /// A dynamic list of paths that should be ignored. Added to during our own file system modifications. /// - private readonly ConcurrentDictionary _tempIgnoredPaths = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _tempIgnoredPaths = new(StringComparer.OrdinalIgnoreCase); - private bool _disposed = false; + private bool _disposed; /// /// Initializes a new instance of the class. @@ -46,34 +46,31 @@ namespace Emby.Server.Implementations.IO /// The library manager. /// The configuration manager. /// The filesystem. + /// The . public LibraryMonitor( ILogger logger, ILibraryManager libraryManager, IServerConfigurationManager configurationManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IHostApplicationLifetime appLifetime) { _libraryManager = libraryManager; _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; - } - /// - /// Add the path to our temporary ignore list. Use when writing to a path within our listening scope. - /// - /// The path. - private void TemporarilyIgnore(string path) - { - _tempIgnoredPaths[path] = path; + appLifetime.ApplicationStarted.Register(Start); } + /// public void ReportFileSystemChangeBeginning(string path) { ArgumentException.ThrowIfNullOrEmpty(path); - TemporarilyIgnore(path); + _tempIgnoredPaths[path] = path; } + /// public async void ReportFileSystemChangeComplete(string path, bool refreshPath) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -107,14 +104,10 @@ namespace Emby.Server.Implementations.IO var options = _libraryManager.GetLibraryOptions(item); - if (options is not null) - { - return options.EnableRealtimeMonitor; - } - - return false; + return options is not null && options.EnableRealtimeMonitor; } + /// public void Start() { _libraryManager.ItemAdded += OnLibraryManagerItemAdded; @@ -306,20 +299,11 @@ namespace Emby.Server.Implementations.IO { if (removeFromList) { - RemoveWatcherFromList(watcher); + _fileSystemWatchers.TryRemove(watcher.Path, out _); } } } - /// - /// Removes the watcher from list. - /// - /// The watcher. - private void RemoveWatcherFromList(FileSystemWatcher watcher) - { - _fileSystemWatchers.TryRemove(watcher.Path, out _); - } - /// /// Handles the Error event of the watcher control. /// @@ -352,6 +336,7 @@ namespace Emby.Server.Implementations.IO } } + /// public void ReportFileSystemChanged(string path) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -479,31 +464,15 @@ namespace Emby.Server.Implementations.IO } } - /// - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// + /// 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) - { - Stop(); - } - + Stop(); _disposed = true; } } diff --git a/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs b/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs deleted file mode 100644 index c51cf0545..000000000 --- a/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; - -namespace Emby.Server.Implementations.IO -{ - /// - /// which is responsible for starting the library monitor. - /// - public sealed class LibraryMonitorStartup : IServerEntryPoint - { - private readonly ILibraryMonitor _monitor; - - /// - /// Initializes a new instance of the class. - /// - /// The library monitor. - public LibraryMonitorStartup(ILibraryMonitor monitor) - { - _monitor = monitor; - } - - /// - public Task RunAsync() - { - _monitor.Start(); - return Task.CompletedTask; - } - - /// - public void Dispose() - { - } - } -} diff --git a/MediaBrowser.Controller/Library/ILibraryMonitor.cs b/MediaBrowser.Controller/Library/ILibraryMonitor.cs index de74aa5a1..6d2f5b873 100644 --- a/MediaBrowser.Controller/Library/ILibraryMonitor.cs +++ b/MediaBrowser.Controller/Library/ILibraryMonitor.cs @@ -1,10 +1,9 @@ -#pragma warning disable CS1591 - -using System; - namespace MediaBrowser.Controller.Library { - public interface ILibraryMonitor : IDisposable + /// + /// Service responsible for monitoring library filesystems for changes. + /// + public interface ILibraryMonitor { /// /// Starts this instance. -- cgit v1.2.3 From 19a72e8bf2b1a68fddb992357577683027408e90 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:38:12 -0500 Subject: Remove IServerEntryPoint --- Emby.Server.Implementations/ApplicationHost.cs | 33 ++-------------------- .../Plugins/IRunBeforeStartup.cs | 9 ------ .../Plugins/IServerEntryPoint.cs | 20 ------------- 3 files changed, 2 insertions(+), 60 deletions(-) delete mode 100644 MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs delete mode 100644 MediaBrowser.Controller/Plugins/IServerEntryPoint.cs diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index d268a6ba8..550c16b4c 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -62,7 +62,6 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Resolvers; @@ -393,7 +392,7 @@ namespace Emby.Server.Implementations /// Runs the startup tasks. /// /// . - public async Task RunStartupTasksAsync() + public Task RunStartupTasksAsync() { Logger.LogInformation("Running startup tasks"); @@ -405,38 +404,10 @@ namespace Emby.Server.Implementations Resolve().SetFFmpegPath(); Logger.LogInformation("ServerId: {ServerId}", SystemId); - - var entryPoints = GetExports(); - - var stopWatch = new Stopwatch(); - stopWatch.Start(); - - await Task.WhenAll(StartEntryPoints(entryPoints, true)).ConfigureAwait(false); - Logger.LogInformation("Executed all pre-startup entry points in {Elapsed:g}", stopWatch.Elapsed); - Logger.LogInformation("Core startup complete"); CoreStartupHasCompleted = true; - stopWatch.Restart(); - - await Task.WhenAll(StartEntryPoints(entryPoints, false)).ConfigureAwait(false); - Logger.LogInformation("Executed all post-startup entry points in {Elapsed:g}", stopWatch.Elapsed); - stopWatch.Stop(); - } - - private IEnumerable StartEntryPoints(IEnumerable entryPoints, bool isBeforeStartup) - { - foreach (var entryPoint in entryPoints) - { - if (isBeforeStartup != (entryPoint is IRunBeforeStartup)) - { - continue; - } - - Logger.LogDebug("Starting entry point {Type}", entryPoint.GetType()); - - yield return entryPoint.RunAsync(); - } + return Task.CompletedTask; } /// diff --git a/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs b/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs deleted file mode 100644 index 2b831103a..000000000 --- a/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace MediaBrowser.Controller.Plugins -{ - /// - /// Indicates that a should be invoked as a pre-startup task. - /// - public interface IRunBeforeStartup - { - } -} diff --git a/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs b/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs deleted file mode 100644 index 6024661e1..000000000 --- a/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace MediaBrowser.Controller.Plugins -{ - /// - /// Represents an entry point for a module in the application. This interface is scanned for automatically and - /// provides a hook to initialize the module at application start. - /// The entry point can additionally be flagged as a pre-startup task by implementing the - /// interface. - /// - public interface IServerEntryPoint : IDisposable - { - /// - /// Run the initialization for this module. This method is invoked at application start. - /// - /// A representing the asynchronous operation. - Task RunAsync(); - } -} -- 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 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 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(-) 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(-) 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 59a9586dbdbc6c7fc9f3ef841183f736fb07eb23 Mon Sep 17 00:00:00 2001 From: Damian Kacperski <7dami77@gmail.com> Date: Fri, 9 Feb 2024 20:41:32 +0100 Subject: Add PlaybackOrder to Session state --- Emby.Server.Implementations/Session/SessionManager.cs | 1 + MediaBrowser.Model/Session/GeneralCommandType.cs | 3 ++- MediaBrowser.Model/Session/PlaybackOrder.cs | 18 ++++++++++++++++++ MediaBrowser.Model/Session/PlaybackProgressInfo.cs | 6 ++++++ MediaBrowser.Model/Session/PlayerStateInfo.cs | 6 ++++++ 5 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 MediaBrowser.Model/Session/PlaybackOrder.cs diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index bbb3938dc..40b3b0339 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -394,6 +394,7 @@ namespace Emby.Server.Implementations.Session session.PlayState.SubtitleStreamIndex = info.SubtitleStreamIndex; session.PlayState.PlayMethod = info.PlayMethod; session.PlayState.RepeatMode = info.RepeatMode; + session.PlayState.PlaybackOrder = info.PlaybackOrder; session.PlaylistItemId = info.PlaylistItemId; var nowPlayingQueue = info.NowPlayingQueue; diff --git a/MediaBrowser.Model/Session/GeneralCommandType.cs b/MediaBrowser.Model/Session/GeneralCommandType.cs index 166a6b441..09339928c 100644 --- a/MediaBrowser.Model/Session/GeneralCommandType.cs +++ b/MediaBrowser.Model/Session/GeneralCommandType.cs @@ -48,6 +48,7 @@ namespace MediaBrowser.Model.Session PlayNext = 38, ToggleOsdMenu = 39, Play = 40, - SetMaxStreamingBitrate = 41 + SetMaxStreamingBitrate = 41, + SetPlaybackOrder = 42 } } diff --git a/MediaBrowser.Model/Session/PlaybackOrder.cs b/MediaBrowser.Model/Session/PlaybackOrder.cs new file mode 100644 index 000000000..8ef7faf14 --- /dev/null +++ b/MediaBrowser.Model/Session/PlaybackOrder.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.Session +{ + /// + /// Enum PlaybackOrder. + /// + public enum PlaybackOrder + { + /// + /// Sorted playlist. + /// + Default = 0, + + /// + /// Shuffled playlist. + /// + Shuffle = 1 + } +} diff --git a/MediaBrowser.Model/Session/PlaybackProgressInfo.cs b/MediaBrowser.Model/Session/PlaybackProgressInfo.cs index a6e7efcb0..04a9d6867 100644 --- a/MediaBrowser.Model/Session/PlaybackProgressInfo.cs +++ b/MediaBrowser.Model/Session/PlaybackProgressInfo.cs @@ -107,6 +107,12 @@ namespace MediaBrowser.Model.Session /// The repeat mode. public RepeatMode RepeatMode { get; set; } + /// + /// Gets or sets the playback order. + /// + /// The playback order. + public PlaybackOrder PlaybackOrder { get; set; } + public QueueItem[] NowPlayingQueue { get; set; } public string PlaylistItemId { get; set; } diff --git a/MediaBrowser.Model/Session/PlayerStateInfo.cs b/MediaBrowser.Model/Session/PlayerStateInfo.cs index 80e6d4e0b..35cd68fd1 100644 --- a/MediaBrowser.Model/Session/PlayerStateInfo.cs +++ b/MediaBrowser.Model/Session/PlayerStateInfo.cs @@ -65,6 +65,12 @@ namespace MediaBrowser.Model.Session /// The repeat mode. public RepeatMode RepeatMode { get; set; } + /// + /// Gets or sets the playback order. + /// + /// The playback order. + public PlaybackOrder PlaybackOrder { get; set; } + /// /// Gets or sets the now playing live stream identifier. /// -- cgit v1.2.3 From f34c56282d106029d4739d66767c6397f0db081b Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 10 Feb 2024 16:52:21 +0100 Subject: Use concat config for BDMV/DVD folder attachment extraction --- .../Transcoding/TranscodeManager.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index ab3eb3298..146b30643 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -405,7 +405,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) { - this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + OnTranscodeFailedToStart(outputPath, transcodingJobType, state); throw new ArgumentException("User does not have access to video transcoding."); } @@ -417,7 +417,12 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) { var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); - if (state.VideoType != VideoType.Dvd) + if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) + { + var concatPath = Path.Join(_serverConfigurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat"); + await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); + } + else { await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); } @@ -432,7 +437,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } - var process = new Process + using var process = new Process { StartInfo = new ProcessStartInfo { @@ -452,7 +457,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable EnableRaisingEvents = true }; - var transcodingJob = this.OnTranscodeBeginning( + var transcodingJob = OnTranscodeBeginning( outputPath, state.Request.PlaySessionId, state.MediaSource.LiveStreamId, @@ -507,7 +512,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable catch (Exception ex) { _logger.LogError(ex, "Error starting FFmpeg"); - this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + OnTranscodeFailedToStart(outputPath, transcodingJobType, state); throw; } -- cgit v1.2.3 From 52c79c050b638dc6556351346fa26d2eb81775b3 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 10 Feb 2024 16:53:38 +0100 Subject: Order files before creating concat config --- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 46 +++++++++++----------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index f86d14fc8..cc6971c1b 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -1111,6 +1111,7 @@ namespace MediaBrowser.MediaEncoding.Encoder return allVobs .Where(vob => titles.Contains(_fileSystem.GetFileNameWithoutExtension(vob).AsSpan().RightPart('_').ToString())) .Select(i => i.FullName) + .Order() .ToList(); } @@ -1127,6 +1128,7 @@ namespace MediaBrowser.MediaEncoding.Encoder return directoryFiles .Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase)) .Select(f => f.FullName) + .Order() .ToList(); } @@ -1150,31 +1152,29 @@ namespace MediaBrowser.MediaEncoding.Encoder } // Generate concat configuration entries for each file and write to file - using (StreamWriter sw = new StreamWriter(concatFilePath)) + using StreamWriter sw = new StreamWriter(concatFilePath); + foreach (var path in files) { - foreach (var path in files) - { - var mediaInfoResult = GetMediaInfo( - new MediaInfoRequest + var mediaInfoResult = GetMediaInfo( + new MediaInfoRequest + { + MediaType = DlnaProfileType.Video, + MediaSource = new MediaSourceInfo { - MediaType = DlnaProfileType.Video, - MediaSource = new MediaSourceInfo - { - Path = path, - Protocol = MediaProtocol.File, - VideoType = videoType - } - }, - CancellationToken.None).GetAwaiter().GetResult(); - - var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds; - - // Add file path stanza to concat configuration - sw.WriteLine("file '{0}'", path); - - // Add duration stanza to concat configuration - sw.WriteLine("duration {0}", duration); - } + Path = path, + Protocol = MediaProtocol.File, + VideoType = videoType + } + }, + CancellationToken.None).GetAwaiter().GetResult(); + + var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds; + + // Add file path stanza to concat configuration + sw.WriteLine("file '{0}'", path); + + // Add duration stanza to concat configuration + sw.WriteLine("duration {0}", duration); } } -- cgit v1.2.3 From 7baa261b22b03fbf8b77c2ae04387af97acfdfc2 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 10 Feb 2024 16:56:21 +0100 Subject: DVD and BDMV folders can not be served directly --- Jellyfin.Api/Controllers/VideosController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index e6c319869..83f04d5be 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -461,7 +461,7 @@ public class VideosController : BaseJellyfinApiController var outputPath = state.OutputFilePath; // Static stream - if (@static.HasValue && @static.Value) + if (@static.HasValue && @static.Value && !(state.MediaSource.VideoType == VideoType.BluRay || state.MediaSource.VideoType == VideoType.Dvd)) { var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); -- cgit v1.2.3 From 9230472056ea92a54a3f47bcb10142310ae0200e Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 10 Feb 2024 16:57:10 +0100 Subject: Fix file extension based on container --- Jellyfin.Api/Controllers/VideosController.cs | 4 +--- Jellyfin.Api/Helpers/StreamingHelpers.cs | 21 ++++++++++++++++++++- .../MediaEncoding/EncodingHelper.cs | 3 ++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 83f04d5be..b3029d6fa 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -458,8 +458,6 @@ public class VideosController : BaseJellyfinApiController return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); } - var outputPath = state.OutputFilePath; - // Static stream if (@static.HasValue && @static.Value && !(state.MediaSource.VideoType == VideoType.BluRay || state.MediaSource.VideoType == VideoType.Dvd)) { @@ -478,7 +476,7 @@ public class VideosController : BaseJellyfinApiController // Need to start ffmpeg (because media can't be returned directly) var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, "superfast"); return await FileStreamResponseHelpers.GetTranscodedFile( state, isHeadRequest, diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 7a3842a9f..bfe71fd87 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -225,7 +225,7 @@ public static class StreamingHelpers var ext = string.IsNullOrWhiteSpace(state.OutputContainer) ? GetOutputFileExtension(state, mediaSource) - : ("." + state.OutputContainer); + : ("." + GetContainerFileExtension(state.OutputContainer)); state.OutputFilePath = GetOutputFilePath(state, ext, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); @@ -559,4 +559,23 @@ public static class StreamingHelpers } } } + + /// + /// Parses the container into its file extension. + /// + /// The container. + private static string? GetContainerFileExtension(string? container) + { + if (string.Equals(container, "mpegts", StringComparison.OrdinalIgnoreCase)) + { + return "ts"; + } + + if (string.Equals(container, "matroska", StringComparison.OrdinalIgnoreCase)) + { + return "mkv"; + } + + return container; + } } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 1c95192f1..bb867aba3 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -6541,13 +6541,14 @@ namespace MediaBrowser.Controller.MediaEncoding return " -codec:s:0 " + codec + " -disposition:s:0 default"; } - public string GetProgressiveVideoFullCommandLine(EncodingJobInfo state, EncodingOptions encodingOptions, string outputPath, string defaultPreset) + public string GetProgressiveVideoFullCommandLine(EncodingJobInfo state, EncodingOptions encodingOptions, string defaultPreset) { // Get the output codec name var videoCodec = GetVideoEncoder(state, encodingOptions); var format = string.Empty; var keyFrame = string.Empty; + var outputPath = state.OutputFilePath; if (Path.GetExtension(outputPath.AsSpan()).Equals(".mp4", StringComparison.OrdinalIgnoreCase) && state.BaseRequest.Context == EncodingContext.Streaming) -- cgit v1.2.3 From 2d1164066a9ccc4a7254f5de168b8a2f5760dee7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 10 Feb 2024 23:26:45 +0000 Subject: Update dependency IDisposableAnalyzers to v4.0.7 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a236e5b6..97173d196 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,7 @@ - + -- cgit v1.2.3 From aaa9345a533d6e8c3fa10b12672de4766ef12070 Mon Sep 17 00:00:00 2001 From: felix920506 Date: Sat, 10 Feb 2024 23:39:30 -0500 Subject: Correct m4b mimetype (#10980) --- CONTRIBUTORS.md | 1 + MediaBrowser.Model/Net/MimeTypes.cs | 2 +- tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 3be6331c8..a8ee693ec 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -179,6 +179,7 @@ - [Çağrı Sakaoğlu](https://github.com/ilovepilav) _ [Barasingha](https://github.com/MaVdbussche) - [Gauvino](https://github.com/Gauvino) + - [felix920506](https://github.com/felix920506) # Emby Contributors diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index 5a1871070..7b510a337 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -87,7 +87,7 @@ namespace MediaBrowser.Model.Net { ".dsf", "audio/dsf" }, { ".dsp", "audio/dsp" }, { ".flac", "audio/flac" }, - { ".m4b", "audio/m4b" }, + { ".m4b", "audio/mp4" }, { ".mp3", "audio/mpeg" }, { ".vorbis", "audio/vorbis" }, { ".webma", "audio/webm" }, diff --git a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs index 371c3811a..ccdf01758 100644 --- a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs +++ b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs @@ -69,7 +69,7 @@ namespace Jellyfin.Model.Tests.Net [InlineData(".dsp", "audio/dsp")] [InlineData(".flac", "audio/flac")] [InlineData(".m4a", "audio/mp4")] - [InlineData(".m4b", "audio/m4b")] + [InlineData(".m4b", "audio/mp4")] [InlineData(".mid", "audio/midi")] [InlineData(".midi", "audio/midi")] [InlineData(".mp3", "audio/mpeg")] -- cgit v1.2.3 From 3cf0070287ae561ead456aea059e8d49e24bb63f Mon Sep 17 00:00:00 2001 From: felix920506 Date: Sun, 11 Feb 2024 00:51:09 -0500 Subject: Escape subtitle extraction input path (#10992) --- MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 8f1cc3f64..4b1b1bbc6 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -509,7 +509,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputPaths = new List(); var args = string.Format( CultureInfo.InvariantCulture, - "-i {0} -copyts", + "-i \"{0}\" -copyts", inputPath); foreach (var subtitleStream in subtitleStreams) @@ -680,7 +680,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var processArgs = string.Format( CultureInfo.InvariantCulture, - "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", + "-i \"{0}\" -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", inputPath, subtitleStreamIndex, outputCodec, -- cgit v1.2.3 From 07b9c85f143afe49195cf463961c0829ba8ff231 Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo <56478732+dmitrylyzo@users.noreply.github.com> Date: Mon, 12 Feb 2024 17:34:26 +0300 Subject: test: discard webm from test mkvs if there is an unsupported codec (#10999) To comply 39088b5ad29cf098729c31f0be90a387df5debf6, ba877283a17f9f1ef32569669989e6d72cc571c5 --- tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs | 10 +++++----- .../Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json | 2 +- .../Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json | 2 +- .../Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 2f84fa544..d9dceee55 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -28,7 +28,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Firefox [InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 @@ -39,7 +39,7 @@ namespace Jellyfin.Model.Tests [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Safari [InlineData("SafariNext", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 @@ -90,7 +90,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // TranscodeMedia [InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] @@ -178,7 +178,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Firefox [InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 @@ -188,7 +188,7 @@ namespace Jellyfin.Model.Tests [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Safari [InlineData("SafariNext", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json index da185aacf..e528281bd 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json @@ -1,7 +1,7 @@ { "Id": "a766d122b58e45d9492d17af66748bf5", "Path": "/Media/MyVideo-720p.mkv", - "Container": "mkv,webm", + "Container": "mkv", "Size": 835317696, "Name": "MyVideo-1080p", "ETag": "579a34c6d5dfb23f61539a51220b6a23", diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json index 0a85a1353..8ef10ae87 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json @@ -1,7 +1,7 @@ { "Id": "a766d122b58e45d9492d17af66748bf5", "Path": "/Media/MyVideo-720p.mkv", - "Container": "mkv,webm", + "Container": "mkv", "Size": 835317696, "Name": "MyVideo-1080p", "ETag": "579a34c6d5dfb23f61539a51220b6a23", diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json index 2b932ff52..80a9f4103 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json @@ -1,7 +1,7 @@ { "Id": "a766d122b58e45d9492d17af66748bf5", "Path": "/Media/MyVideo-720p.mkv", - "Container": "mkv,webm", + "Container": "mkv", "Size": 835317696, "Name": "MyVideo-1080p", "ETag": "579a34c6d5dfb23f61539a51220b6a23", -- cgit v1.2.3 From 64ce3c8411a2ae0bba9c00046a398e75479f3470 Mon Sep 17 00:00:00 2001 From: Nyanmisaka Date: Mon, 12 Feb 2024 22:34:35 +0800 Subject: Add icon for Jellyfin Windows build (#10997) Add icon to make it easier for Windows users to recognize. This requires dotnet 8 or newer. Signed-off-by: nyanmisaka --- Jellyfin.Server/Jellyfin.Server.csproj | 5 +++++ Jellyfin.Server/Jellyfin.Server.ico | Bin 0 -> 40883 bytes 2 files changed, 5 insertions(+) create mode 100644 Jellyfin.Server/Jellyfin.Server.ico diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 21c6e6f01..e18212908 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -12,12 +12,17 @@ false false true + Jellyfin.Server.ico + + + + diff --git a/Jellyfin.Server/Jellyfin.Server.ico b/Jellyfin.Server/Jellyfin.Server.ico new file mode 100644 index 000000000..0872b956a Binary files /dev/null and b/Jellyfin.Server/Jellyfin.Server.ico differ -- cgit v1.2.3 From 0dde8c08082754cb289d7b40df633b2828274b39 Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo <56478732+dmitrylyzo@users.noreply.github.com> Date: Mon, 12 Feb 2024 17:36:54 +0300 Subject: Make the bitrate of the fallback stream the same as the original (#9121) --- Jellyfin.Api/Helpers/DynamicHlsHelper.cs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index b0c17c835..f8d89119a 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -211,19 +211,8 @@ public class DynamicHlsHelper var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); sdrVideoUrl += "&AllowVideoStreamCopy=false"; - var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - var sdrOutputAudioBitrate = 0; - if (EncodingHelper.LosslessAudioCodecs.Contains(state.VideoRequest.AudioCodec, StringComparison.OrdinalIgnoreCase)) - { - sdrOutputAudioBitrate = state.AudioStream.BitRate ?? 0; - } - else - { - sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream, state.OutputAudioChannels) ?? 0; - } - - var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; - AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); + // HACK: Use the same bitrate so that the client can choose by other attributes, such as color range. + AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup); // Restore the video codec state.OutputVideoCodec = "copy"; -- cgit v1.2.3 From f359d2e5eca9abda77c4072006b774c3faae52c7 Mon Sep 17 00:00:00 2001 From: sleepycatcoding Date: Sun, 11 Feb 2024 14:49:09 +0000 Subject: Translated using Weblate (Estonian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/ --- Emby.Server.Implementations/Localization/Core/et.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index c78ffa28c..977307b06 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -31,7 +31,7 @@ "VersionNumber": "Versioon {0}", "ValueSpecialEpisodeName": "Eriepisood - {0}", "ValueHasBeenAddedToLibrary": "{0} lisati meediakogusse", - "UserStartedPlayingItemWithValues": "{0} taasesitab {1} serveris {2}", + "UserStartedPlayingItemWithValues": "{0} taasesitab {1} seadmes {2}", "UserPasswordChangedWithName": "Kasutaja {0} parool muudeti", "UserLockedOutWithName": "Kasutaja {0} lukustati", "UserDeletedWithName": "Kasutaja {0} kustutati", -- cgit v1.2.3 From ac906a04e222d3c114424944f2dd7b5127b0b370 Mon Sep 17 00:00:00 2001 From: Nick <20588554+nicknsy@users.noreply.github.com> Date: Mon, 12 Feb 2024 09:30:47 -0800 Subject: Fix tiles playlist not using relative paths --- Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index f6854157a..095bc9ed3 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -382,7 +382,7 @@ public class TrickplayManager : ITrickplayManager if (trickplayInfo.ThumbnailCount > 0) { - const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}"; + const string urlFormat = "{0}.jpg?MediaSourceId={1}&api_key={2}"; const string decimalFormat = "{0:0.###}"; var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}"; @@ -431,7 +431,6 @@ public class TrickplayManager : ITrickplayManager .AppendFormat( CultureInfo.InvariantCulture, urlFormat, - width.ToString(CultureInfo.InvariantCulture), i.ToString(CultureInfo.InvariantCulture), itemId.ToString("N"), apiKey) -- cgit v1.2.3 From 749209ce6c7229e68af460673a54597e9f0167dd Mon Sep 17 00:00:00 2001 From: mikikuzmanoski Date: Mon, 12 Feb 2024 19:55:57 +0000 Subject: Translated using Weblate (Macedonian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/mk/ --- Emby.Server.Implementations/Localization/Core/mk.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json index cbccad87f..7ef907918 100644 --- a/Emby.Server.Implementations/Localization/Core/mk.json +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -122,5 +122,6 @@ "TaskRefreshChapterImagesDescription": "Создава тамбнеил за видеата шти имаат поглавја.", "TaskCleanActivityLogDescription": "Избришува логови на активности постари од определеното време.", "TaskCleanActivityLog": "Избриши Лог на Активности", - "External": "Надворешен" + "External": "Надворешен", + "HearingImpaired": "Оштетен слух" } -- cgit v1.2.3 From 3e24ec68d3a32e68eccf295745c9e07236309058 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Feb 2024 15:39:26 +0000 Subject: chore(deps): update github/codeql-action action to v3.24.1 --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 839bebb96..1ae7658eb 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 + uses: github/codeql-action/init@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 + uses: github/codeql-action/autobuild@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 + uses: github/codeql-action/analyze@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1 -- cgit v1.2.3 From 9c78cafe702ea321872b621e7c1a1b1b06ace18c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Feb 2024 13:58:17 +0000 Subject: chore(deps): update github/codeql-action action to v3.24.3 --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 1ae7658eb..f8012e90e 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1 + uses: github/codeql-action/init@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1 + uses: github/codeql-action/autobuild@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1 + uses: github/codeql-action/analyze@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 -- cgit v1.2.3 From 6c1025f2cd947979130404f115a3a51a63df6988 Mon Sep 17 00:00:00 2001 From: gearoidkeane Date: Thu, 15 Feb 2024 15:47:44 -0500 Subject: Added translation using Weblate (Irish) --- Emby.Server.Implementations/Localization/Core/ga.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 Emby.Server.Implementations/Localization/Core/ga.json diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -0,0 +1 @@ +{} -- cgit v1.2.3 From 2bd85df383ec70251a8cdf9802963e29b52b60a4 Mon Sep 17 00:00:00 2001 From: Robert Lützner Date: Thu, 15 Feb 2024 22:15:14 +0000 Subject: Add missing MIME types for comicbook formats (#11010) * Correct MIME types for comicbook file extensions cb7, cba, cbr, cbt and cbz all refer to different types of digital comicbooks. The last letter of the extension indicates the compression algorithm that was used: 7zip, arc, rar, tar or zip. All these filetypes used to have the `application/x-cbr` MIME type assigned to them. However, that has since been deprecated and was replaced with - `application/vnd.comicbook-rar` for rar compressed files and - `application/vnd.comicbook+zip` for rar compressed files. Only these two are officially listed by IANA https://www.iana.org/assignments/media-types/application/vnd.comicbook+zip . cbr and cbz are by far the most common file extensions for comicbooks. There's no official MIME type for cb7, cba or cbt files. However, with rar being a proprietary compression algorithm, FOSS applications will often refuse to handle files that identify themselves as `application/x-cbr`, so I decided to assign extension specific MIME types to them. I've seen these being used by other applications, specifically comic book readers. I've read through the docs on iana.org, but haven't figured out why they chose `-rar`, but `+zip`. * Add conversions from MIME type to file extensions for comicbook formats cb7, cba, cbr, cbt and cbz all refer to different types of digital comicbooks. The last letter of the extension indicates the compression algorithm that was used: 7zip, arc, rar, tar or zip. All these filetypes used to have the `application/x-cbr` MIME type assigned to them. However, that has since been deprecated and was replaced with - `application/vnd.comicbook-rar` for rar compressed files and - `application/vnd.comicbook+zip` for rar compressed files. Only these two are officially listed by IANA https://www.iana.org/assignments/media-types/application/vnd.comicbook+zip . cbr and cbz are by far the most common file extensions for comicbooks. There's no official MIME type for cb7, cba or cbt files. However, with rar being a proprietary compression algorithm, FOSS applications will often refuse to handle files that identify themselves as `application/x-cbr`, so I decided to assign extension specific MIME types to them. I've seen these being used by other applications, specifically comic book readers. * Update CONTRIBUTORS.md --- CONTRIBUTORS.md | 1 + MediaBrowser.Model/Net/MimeTypes.cs | 11 +++++++++++ tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs | 11 +++++++++++ 3 files changed, 23 insertions(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a8ee693ec..55642e4e2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -251,3 +251,4 @@ - [Utku Özdemir](https://github.com/utkuozdemir) - [JPUC1143](https://github.com/Jpuc1143/) - [0x25CBFC4F](https://github.com/0x25CBFC4F) + - [Robert Lützner](https://github.com/rluetzner) diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index 7b510a337..90035f18f 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -66,6 +66,11 @@ namespace MediaBrowser.Model.Net { // Type application { ".azw3", "application/vnd.amazon.ebook" }, + { ".cb7", "application/x-cb7" }, + { ".cba", "application/x-cba" }, + { ".cbr", "application/vnd.comicbook-rar" }, + { ".cbt", "application/x-cbt" }, + { ".cbz", "application/vnd.comicbook+zip" }, // Type image { ".tbn", "image/jpeg" }, @@ -98,6 +103,12 @@ namespace MediaBrowser.Model.Net private static readonly Dictionary _extensionLookup = new Dictionary(StringComparer.OrdinalIgnoreCase) { // Type application + { "application/vnd.comicbook-rar", ".cbr" }, + { "application/vnd.comicbook+zip", ".cbz" }, + { "application/x-cb7", ".cb7" }, + { "application/x-cba", ".cba" }, + { "application/x-cbr", ".cbr" }, + { "application/x-cbt", ".cbt" }, { "application/x-cbz", ".cbz" }, { "application/x-javascript", ".js" }, { "application/xml", ".xml" }, diff --git a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs index ccdf01758..a18a85ec0 100644 --- a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs +++ b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs @@ -6,6 +6,11 @@ namespace Jellyfin.Model.Tests.Net public class MimeTypesTests { [Theory] + [InlineData(".cb7", "application/x-cb7")] + [InlineData(".cba", "application/x-cba")] + [InlineData(".cbr", "application/vnd.comicbook-rar")] + [InlineData(".cbt", "application/x-cbt")] + [InlineData(".cbz", "application/vnd.comicbook+zip")] [InlineData(".dll", "application/octet-stream")] [InlineData(".log", "text/plain")] [InlineData(".srt", "application/x-subrip")] @@ -94,10 +99,16 @@ namespace Jellyfin.Model.Tests.Net [InlineData("application/pdf", ".pdf")] [InlineData("application/ttml+xml", ".ttml")] [InlineData("application/vnd.amazon.ebook", ".azw")] + [InlineData("application/vnd.comicbook-rar", ".cbr")] + [InlineData("application/vnd.comicbook+zip", ".cbz")] [InlineData("application/vnd.ms-fontobject", ".eot")] [InlineData("application/vnd.rar", ".rar")] [InlineData("application/wasm", ".wasm")] [InlineData("application/x-7z-compressed", ".7z")] + [InlineData("application/x-cb7", ".cb7")] + [InlineData("application/x-cba", ".cba")] + [InlineData("application/x-cbr", ".cbr")] + [InlineData("application/x-cbt", ".cbt")] [InlineData("application/x-cbz", ".cbz")] [InlineData("application/x-javascript", ".js")] [InlineData("application/x-mobipocket-ebook", ".mobi")] -- cgit v1.2.3 From 4d93f067320a83acd76f445fb0aabf828922e140 Mon Sep 17 00:00:00 2001 From: gearoidkeane Date: Thu, 15 Feb 2024 20:55:06 +0000 Subject: Translated using Weblate (Irish) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ga/ --- Emby.Server.Implementations/Localization/Core/ga.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 0967ef424..28e54bff5 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -1 +1,3 @@ -{} +{ + "Albums": "Albaim" +} -- cgit v1.2.3 From 5b93aec2f5814f92acf3a517baf52c6e1ee952db Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sat, 17 Feb 2024 14:29:34 +0100 Subject: Always make userId query parameter optional --- Jellyfin.Api/Controllers/DisplayPreferencesController.cs | 15 ++++++++++----- Jellyfin.Api/Controllers/MediaInfoController.cs | 3 ++- Jellyfin.Api/Controllers/PlaylistsController.cs | 7 ++++--- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 6f0006832..1cad66326 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -48,15 +49,17 @@ public class DisplayPreferencesController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] public ActionResult GetDisplayPreferences( [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery, Required] string client) { + userId = RequestHelpers.GetUserId(User, userId); + if (!Guid.TryParse(displayPreferencesId, out var itemId)) { itemId = displayPreferencesId.GetMD5(); } - var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client); var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); itemPreferences.ItemId = itemId; @@ -113,10 +116,12 @@ public class DisplayPreferencesController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] public ActionResult UpdateDisplayPreferences( [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery, Required] string client, [FromBody, Required] DisplayPreferencesDto displayPreferences) { + userId = RequestHelpers.GetUserId(User, userId); + HomeSectionType[] defaults = { HomeSectionType.SmallLibraryTiles, @@ -134,7 +139,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController itemId = displayPreferencesId.GetMD5(); } - var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client); existingDisplayPreferences.IndexBy = Enum.TryParse(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; @@ -204,7 +209,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController itemPrefs.ItemId = itemId; // Set all remaining custom preferences. - _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); + _displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); _displayPreferencesManager.SaveChanges(); return NoContent(); diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index bea545cfd..742012b71 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -64,8 +64,9 @@ public class MediaInfoController : BaseJellyfinApiController /// A containing a with the playback information. [HttpGet("Items/{itemId}/PlaybackInfo")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId) + public async Task> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { + userId = RequestHelpers.GetUserId(User, userId); return await _mediaInfoHelper.GetPlaybackInfo( itemId, userId) diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 921cc6031..0e7c3f155 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -174,7 +174,7 @@ public class PlaylistsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult> GetPlaylistItems( [FromRoute, Required] Guid playlistId, - [FromQuery, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -183,15 +183,16 @@ public class PlaylistsController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { + userId = RequestHelpers.GetUserId(User, userId); var playlist = (Playlist)_libraryManager.GetItemById(playlistId); if (playlist is null) { return NotFound(); } - var user = userId.IsEmpty() + var user = userId.IsNullOrEmpty() ? null - : _userManager.GetUserById(userId); + : _userManager.GetUserById(userId.Value); var items = playlist.GetManageableItems().ToArray(); var count = items.Length; -- cgit v1.2.3 From fd957ec7f46f9b7b75b19958bdee371b2ae45862 Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sun, 18 Feb 2024 13:18:16 +0100 Subject: Make userId optional in GetProgramsDto --- Jellyfin.Api/Controllers/LiveTvController.cs | 2 +- Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index da68c72c9..90fedecd6 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -628,7 +628,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvAccess)] public async Task>> GetPrograms([FromBody] GetProgramsDto body) { - var user = body.UserId.IsEmpty() ? null : _userManager.GetUserById(body.UserId); + var user = body.UserId.IsNullOrEmpty() ? null : _userManager.GetUserById(body.UserId.Value); var query = new InternalItemsQuery(user) { diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index 6a30de5e6..8482b1cf1 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -22,7 +22,7 @@ public class GetProgramsDto /// /// Gets or sets optional. Filter by user id. /// - public Guid UserId { get; set; } + public Guid? UserId { get; set; } /// /// Gets or sets the minimum premiere start date. -- cgit v1.2.3 From aa3aaa94fe14554354e2897fe95cf5cd2bf2ca6f Mon Sep 17 00:00:00 2001 From: Nyanmisaka Date: Wed, 21 Feb 2024 01:49:39 +0800 Subject: Fix the preproc filters for dvbsub burn-in (#11034) Signed-off-by: nyanmisaka --- MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index bb867aba3..b6738e7cc 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2988,7 +2988,7 @@ namespace MediaBrowser.Controller.MediaEncoding { return string.Format( CultureInfo.InvariantCulture, - @"scale=-1:{1}:fast_bilinear,crop,pad=max({0}\,iw):max({1}\,ih):(ow-iw)/2:(oh-ih)/2:black@0,crop={0}:{1}", + @"scale=-1:{1}:fast_bilinear,scale,crop,pad=max({0}\,iw):max({1}\,ih):(ow-iw)/2:(oh-ih)/2:black@0,crop={0}:{1}", outWidth.Value, outHeight.Value); } -- cgit v1.2.3 From 84639948c75f1bf27bcff446450ad9137ecd8370 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 20 Feb 2024 10:49:55 -0700 Subject: chore(deps): update dependency efcoresecondlevelcacheinterceptor to v4.2.1 (#11035) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 97173d196..0370ed055 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,7 @@ - + -- cgit v1.2.3 From 0325df5732d388c4e4eb28b657d142a805ef9330 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 20 Feb 2024 21:19:47 +0100 Subject: chore(deps): update dependency efcoresecondlevelcacheinterceptor to v4.2.2 (#11038) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0370ed055..9758d08b0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,7 @@ - + -- cgit v1.2.3 From 9880446b7b070edae945b8946b4ab463adfe494b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 20 Feb 2024 14:42:16 -0700 Subject: chore(deps): update xunit-dotnet monorepo (#11016) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9758d08b0..00cd5e920 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -84,8 +84,8 @@ - + - + -- cgit v1.2.3 From 08b830ae7122193ae75f230a2bea008e72a7cbfd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 20 Feb 2024 15:23:09 -0700 Subject: chore(deps): update dotnet monorepo to v8.0.2 (#11004) * chore(deps): update dotnet monorepo to v8.0.2 * update sdk --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Cody Robibero --- .config/dotnet-tools.json | 2 +- Directory.Packages.props | 22 +++++++++++----------- deployment/Dockerfile.centos.amd64 | 2 +- deployment/Dockerfile.fedora.amd64 | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 81fe5add4..d9b689bb6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "8.0.1", + "version": "8.0.2", "commands": [ "dotnet-ef" ] diff --git a/Directory.Packages.props b/Directory.Packages.props index 00cd5e920..e20d6b629 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,15 +24,15 @@ - + - + - - - - - + + + + + @@ -41,13 +41,13 @@ - - + + - + @@ -78,7 +78,7 @@ - + diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index af309b083..6bd7d312c 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -20,7 +20,7 @@ RUN dnf update -yq \ && rm -rf /var/cache/dnf # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/5226a5fa-8c0b-474f-b79a-8984ad7c5beb/3113ccbf789c9fd29972835f0f334b7a/dotnet-sdk-8.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/85bcc525-4e9c-471e-9c1d-96259aa1a315/930833ef34f66fe9ee2643b0ba21621a/dotnet-sdk-8.0.201-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 75a6d1e64..f1dc492de 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -20,7 +20,7 @@ RUN dnf update -yq \ && rm -rf /var/cache/dnf # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/5226a5fa-8c0b-474f-b79a-8984ad7c5beb/3113ccbf789c9fd29972835f0f334b7a/dotnet-sdk-8.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/85bcc525-4e9c-471e-9c1d-96259aa1a315/930833ef34f66fe9ee2643b0ba21621a/dotnet-sdk-8.0.201-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet -- cgit v1.2.3 From ef96b5fa20f6f09e2671cf20efd075f134274314 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 21 Feb 2024 10:01:15 +0100 Subject: chore(deps): update dependency coverlet.collector to v6.0.1 (#11039) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e20d6b629..1d7ebfaf4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + -- 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(-) 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 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 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 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 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(-) 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 81f3220ddd7cf02db743eba87d0e0ad997613bc3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Feb 2024 12:08:27 +0000 Subject: chore(deps): update github/codeql-action action to v3.24.4 --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index f8012e90e..275dc6f3e 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/init@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/autobuild@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/analyze@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4 -- 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 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 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 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 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 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(-) 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(-) 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 a1bb23e98f8da896fc0530933c5fc74d6d4a50c3 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Fri, 23 Feb 2024 09:02:11 -0700 Subject: Add item id to download activity --- Jellyfin.Api/Controllers/LibraryController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index e357588d1..984dc7789 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -913,6 +913,7 @@ public class LibraryController : BaseJellyfinApiController User.GetUserId()) { ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), + ItemId = item.Id.ToString("N", CultureInfo.InvariantCulture) }).ConfigureAwait(false); } catch -- cgit v1.2.3 From f0722e223564b6c9855b079499e51cc95d0251eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 24 Feb 2024 22:25:46 +0000 Subject: chore(deps): update ci dependencies --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- .github/workflows/ci-tests.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 275dc6f3e..6e2da9737 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4 + uses: github/codeql-action/init@47b3d888fe66b639e431abf22ebca059152f1eea # v3.24.5 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4 + uses: github/codeql-action/autobuild@47b3d888fe66b639e431abf22ebca059152f1eea # v3.24.5 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4 + uses: github/codeql-action/analyze@47b3d888fe66b639e431abf22ebca059152f1eea # v3.24.5 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 4b5db14ae..8ee6b3028 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -34,7 +34,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@68f1963d9876d2ac78bfd1c41c395514b7318855 # 5.2.1 + uses: danielpalme/ReportGenerator-GitHub-Action@b067e0c5d288fb4277b9f397b2dc6013f60381f0 # 5.2.2 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" -- cgit v1.2.3 From 36f298e4173b4f08edfdf9a3a2f09e887846e935 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 25 Feb 2024 17:24:54 +0100 Subject: Do not dispose the ffmpeg process --- MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 146b30643..8bace15c6 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -437,7 +437,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } - using var process = new Process + var process = new Process { StartInfo = new ProcessStartInfo { -- cgit v1.2.3 From 59f50ae8b2555b8caa19e743c3ba612e999f75bf Mon Sep 17 00:00:00 2001 From: felix920506 Date: Sun, 25 Feb 2024 21:25:37 -0500 Subject: Remove "Media Playback" option from new issues (#11033) --- .github/ISSUE_TEMPLATE/media_playback.md | 34 -------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/media_playback.md diff --git a/.github/ISSUE_TEMPLATE/media_playback.md b/.github/ISSUE_TEMPLATE/media_playback.md deleted file mode 100644 index b51500f87..000000000 --- a/.github/ISSUE_TEMPLATE/media_playback.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Media playback issue -about: Create a media playback issue report -title: '' -labels: mediaplayback -assignees: '' - ---- - -**Media Info of the file** - - -**Logs** - - -**FFmpeg Logs** - - -**Stats for Nerds Screenshots** - - -**Server System (please complete the following information):** - - OS: [e.g. Docker on Linux, Docker on Windows, Debian, Windows] - - Jellyfin Version: [e.g. 10.0.1] - - Hardware settings & device: [e.g. NVENC on GTX1060, VAAPI on Intel i7 8700K] - - Reverse proxy: [e.g. no, nginx, apache, etc.] - - Other hardware notes: [e.g. Media mounted in CIFS/SMB share, Media mounted from Google Drive] - -**Client System (please complete the following information):** - - Device: [e.g. Apple iPhone XS, Xbox One S, LG OLED55C8, Samsung Galaxy Note9, Custom HTPC] - - OS: [e.g. iOS, Android, Windows, macOS] - - Client: [e.g. Web/Browser, webOS, Android, Android TV, Electron] - - Browser (if Web client): [e.g. Firefox, Chrome, Safari] - - Client and Browser Version: [e.g. 10.3.4 and 68.0] -- 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 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 From 2e0e1ecc99fbcbdf7ca272413ca30c4a63de97d7 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Mon, 26 Feb 2024 15:57:59 -0700 Subject: Rename route parameters that are id to be more descriptive --- Jellyfin.Api/Controllers/InstantMixController.cs | 40 ++++++++++++------------ Jellyfin.Api/Controllers/SubtitleController.cs | 8 ++--- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index e7ff1f986..3cf485299 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -53,7 +53,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given song. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -63,10 +63,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Songs/{id}/InstantMix")] + [HttpGet("Songs/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromSong( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -75,7 +75,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -90,7 +90,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given album. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -100,10 +100,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Albums/{id}/InstantMix")] + [HttpGet("Albums/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromAlbum( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -112,7 +112,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var album = _libraryManager.GetItemById(id); + var album = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -127,7 +127,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given playlist. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -137,10 +137,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Playlists/{id}/InstantMix")] + [HttpGet("Playlists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromPlaylist( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -149,7 +149,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var playlist = (Playlist)_libraryManager.GetItemById(id); + var playlist = (Playlist)_libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -200,7 +200,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given artist. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -210,10 +210,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Artists/{id}/InstantMix")] + [HttpGet("Artists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromArtists( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -222,7 +222,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -237,7 +237,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given item. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -247,10 +247,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Items/{id}/InstantMix")] + [HttpGet("Items/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromItem( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -259,7 +259,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index d6ec40a7e..cc2a630e1 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -161,17 +161,17 @@ public class SubtitleController : BaseJellyfinApiController /// /// Gets the remote subtitles. /// - /// The item id. + /// The item id. /// File returned. /// A with the subtitle file. - [HttpGet("Providers/Subtitles/Subtitles/{id}")] + [HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] [Produces(MediaTypeNames.Application.Octet)] [ProducesFile("text/*")] - public async Task GetRemoteSubtitles([FromRoute, Required] string id) + public async Task GetRemoteSubtitles([FromRoute, Required] string subtitleId) { - var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); + var result = await _subtitleManager.GetRemoteSubtitles(subtitleId, CancellationToken.None).ConfigureAwait(false); return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); } -- cgit v1.2.3 From 4ba1da09e2867146015698ea16bcf0dca0991eea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 23:08:21 +0000 Subject: chore(deps): update actions/download-artifact action to v4.1.3 --- .github/workflows/ci-openapi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 97f1a33e7..c56349941 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -78,12 +78,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + uses: actions/download-artifact@87c55149d96e628cc2ef7e6fc2aab372015aec85 # v4.1.3 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + uses: actions/download-artifact@87c55149d96e628cc2ef7e6fc2aab372015aec85 # v4.1.3 with: name: openapi-base path: openapi-base -- cgit v1.2.3