From d1677dc680338679f06cc506e97f576d16d022b5 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Wed, 3 Jan 2024 16:47:25 +0100 Subject: [PATCH 001/136] AsyncKeyedLock migration --- Directory.Packages.props | 1 + .../Controllers/DynamicHlsController.cs | 149 ++++++++---------- .../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, 125 insertions(+), 185 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6b99ac807e..30bd8ac553 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 dda1e9d561..590cdc33f0 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); } + + 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); + + if (currentTranscodingIndex.HasValue) + { + DeleteLastFile(playlistPath, segmentExtension, 0); + } + + 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); + } + catch + { + state.Dispose(); + throw; + } + + // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + } else { - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - - if (segmentId == -1) + job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + if (job?.TranscodingThrottler is not null) { - _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); - startTranscoding = true; - segmentId = 0; + await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); } - 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); - - if (currentTranscodingIndex.HasValue) - { - DeleteLastFile(playlistPath, segmentExtension, 0); - } - - 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); - } - catch - { - state.Dispose(); - throw; - } - - // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); - } - else - { - job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - if (job?.TranscodingThrottler is not null) - { - await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); - } - } - } - } - finally - { - if (!released) - { - transcodingLock.Release(); } } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 5385979d4a..cb178a61d8 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 c19a12ae7a..3b410d1bac 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 299f294b29..ff91a60a79 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 a4e8194c15..be63513a72 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 459d854bf1..a546c80b43 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 483d0a1d82..db45d2cdd6 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(); } } From 39088b5ad29cf098729c31f0be90a387df5debf6 Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo Date: Mon, 25 Dec 2023 20:52:39 +0300 Subject: [PATCH 002/136] 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 629c300603..1ec0622f84 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) From 366a22da7162560e3010d3a4722f6544149c083f Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo Date: Mon, 25 Dec 2023 20:54:00 +0300 Subject: [PATCH 003/136] 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 344ac8971c..020e20fb8a 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 0000000000..4cc7db80d2 --- /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 + } +} From bbce1beb1d136d849141a5a5e634fed729fc6698 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 10 Jan 2024 16:54:33 -0500 Subject: [PATCH 004/136] 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 3b20cd160b..5c0e96c67b 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( From f87a5490adce83c32362a14518d6f6e3e5a24917 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 10 Jan 2024 17:06:40 -0500 Subject: [PATCH 005/136] Fix disposable analyzer warnings in SchedulesDirect --- .../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 5c0e96c67b..5728146f78 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(); From e47144e7c777751b03caf7cbb64cf93f92725725 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 12:11:16 +0100 Subject: [PATCH 006/136] 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 | 152 +++++++++--------- .../MediaEncoding/ITranscodeManager.cs | 2 +- .../Encoder/MediaEncoder.cs | 12 +- .../Transcoding/TranscodeManager.cs | 2 +- src/Jellyfin.Drawing/ImageProcessor.cs | 16 +- src/Jellyfin.Drawing/Jellyfin.Drawing.csproj | 4 + .../Channels/ChannelManager.cs | 11 +- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 11 +- src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj | 3 +- .../Listings/SchedulesDirect.cs | 37 +++-- 14 files changed, 126 insertions(+), 149 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4e45fd24ad..250f5d54dc 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 ebb6038d81..29b9030ac4 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 68eccf311d..ec6029faf3 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 0ed1578c70..7c4155bfc6 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 b960feb7f3..f6854157a6 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) - { - _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); - return; - } - - 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(); - - // Create tiles - var trickplayInfo = CreateTiles(images, width, options, outputDir); - - // Save tiles info try { - if (trickplayInfo is not null) + if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width)) { - trickplayInfo.ItemId = video.Id; - await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false); - - _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); + _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id); + return; } - else + + // 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) { - throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); + _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); + return; + } + + 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(); + + // Create tiles + var trickplayInfo = CreateTiles(images, width, options, outputDir); + + // Save tiles info + try + { + if (trickplayInfo is not null) + { + trickplayInfo.ItemId = video.Id; + await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false); + + _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); + } + else + { + throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); + } + } + 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); } } 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 3b410d1bac..09bc01f748 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 4dbefca4bb..7d5ec615a0 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 db45d2cdd6..bb61d7fa6a 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 65a8f4e832..213328a39f 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 23c4c0a9a4..4a02f90f95 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 f5ce75ff4d..bf735ddd00 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 439ed965b0..20ede63b07 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 5a826a1da0..c58889740a 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 3b20cd160b..b237f5b166 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 + using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false)) { - 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) + 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; From 420ce6a4faad81be9e9ef5e1f416c51301912049 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 13:45:02 +0100 Subject: [PATCH 007/136] 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 7d5ec615a0..92c3f57c82 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; From 7998e15839ca9058ea05e41132c7858daf261493 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 14:13:17 +0100 Subject: [PATCH 008/136] 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 92c3f57c82..b39ef3eaaa 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); From cc42f4430f522ae055cffd3ab4aeb137d79d51b0 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 14:16:20 +0100 Subject: [PATCH 009/136] 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 b39ef3eaaa..f86d14fc86 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)) { From ebedb06e40853137459c9bdf91e601c8f84120da Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 14 Jan 2024 21:53:15 +0100 Subject: [PATCH 010/136] 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 29b9030ac4..fe251341a9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - + From 7cd60aefb50d2868eb584c60967d459cdca8f80a Mon Sep 17 00:00:00 2001 From: Martin Vandenbussche Date: Mon, 15 Jan 2024 16:19:47 +0100 Subject: [PATCH 011/136] Adding support for proper trailer STRM URL format, along with the deprecated format --- .../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 70e5b66c1e..5408fb6409 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; From c03f5ca6c32d14681921fa084618ca89fbe953ab Mon Sep 17 00:00:00 2001 From: Martin Vandenbussche Date: Mon, 15 Jan 2024 16:23:15 +0100 Subject: [PATCH 012/136] Updating CONTRIBUTORS.md --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4e45fd24ad..3752ba1b06 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 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: [PATCH 013/136] 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 5408fb6409..ec2bdb1e72 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)) { From 59c2ae944ddc0b4231f4e99863cf4c2f2a16e66f Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 09:50:35 -0500 Subject: [PATCH 014/136] Add IGuideManager service --- Jellyfin.Api/Controllers/LiveTvController.cs | 8 +- .../LiveTv/IGuideManager.cs | 26 + .../LiveTv/ILiveTvManager.cs | 6 - .../LiveTvServiceCollectionExtensions.cs | 2 + src/Jellyfin.LiveTv/Guide/GuideManager.cs | 713 ++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 668 +--------------- .../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 27eb88b60f..35cb970474 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 0000000000..9883b9283c --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/IGuideManager.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.LiveTv; + +namespace MediaBrowser.Controller.LiveTv; + +/// +/// Service responsible for managing the Live TV guide. +/// +public interface IGuideManager +{ + /// + /// Gets the guide information. + /// + /// The . + GuideInfo GetGuideInfo(); + + /// + /// Refresh the guide. + /// + /// The to use. + /// The to use. + /// Task representing the refresh operation. + Task RefreshGuide(IProgress progress, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 26f9fe42d3..2dbc2cf82e 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -174,12 +174,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task. Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken); - /// - /// Gets the guide information. - /// - /// GuideInfo. - GuideInfo GetGuideInfo(); - /// /// Gets the recommended programs. /// diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index 5490547ec3..21dab69e05 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 0000000000..21b41e9ccd --- /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 71822f3762..e8fb02c4f8 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 18bd61d999..798ababc27 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() From 3e32f94fb3ab8f817a74e7dd27981174869a0c45 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 09:57:38 -0500 Subject: [PATCH 015/136] Move RefreshGuideScheduledTask to Guide folder --- .../Guide/RefreshGuideScheduledTask.cs | 71 ++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 1 + .../RefreshGuideScheduledTask.cs | 72 ------------------- .../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 0000000000..1c79d6ab3d --- /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 e8fb02c4f8..aa3be2048a 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 798ababc27..0000000000 --- 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 3e4b0e13fc..60be19c68a 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; From 27ab3ef029cc65d3a60812615350de8cd2f5fda4 Mon Sep 17 00:00:00 2001 From: Martin Vandenbussche Date: Wed, 17 Jan 2024 16:46:04 +0100 Subject: [PATCH 016/136] 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 ec2bdb1e72..97cdc68545 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)) { From 502cbe77b2658cbca1a9f25d5e5e78ad4cd63eab Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:10:09 -0500 Subject: [PATCH 017/136] 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 ae0fdb07a1..18831aa4eb 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; } From 5d3acd43e9aeaf8e050a8c917192d8288725804d Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:10:42 -0500 Subject: [PATCH 018/136] 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 18831aa4eb..f157af5eaa 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); From 75c2de110e3d67ac1f9adc684fc26b066a1915ce Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:12:24 -0500 Subject: [PATCH 019/136] 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 1c79d6ab3d..a9fde08501 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 + } }; } } From f0a9639c173a8ade72b0e1de4345c7409da1b78f Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:14:28 -0500 Subject: [PATCH 020/136] 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 f157af5eaa..bfbc6d4cc6 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); } From 08592fb3fec648aff1b5cf64d03d3339b200cac9 Mon Sep 17 00:00:00 2001 From: TelepathicWalrus Date: Wed, 17 Jan 2024 19:36:14 +0000 Subject: [PATCH 021/136] Add ex to catch if cached mediainfo doesnt exist --- .../Library/LiveStreamHelper.cs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 59d705acef..d41845cdf0 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -48,21 +48,26 @@ 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); + + 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"); + } - // _logger.LogDebug("Found cached media info"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deserializing mediainfo cache"); - } - finally - { await jsonStream.DisposeAsync().ConfigureAwait(false); } + catch + { + _logger.LogError("Could not open cached media info"); + } } if (mediaInfo is null) 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: [PATCH 022/136] 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 1ec0622f84..dba7aea6fd 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; From 8fea819b5152f6a38febb9435df18c2fa26d3273 Mon Sep 17 00:00:00 2001 From: Attila Szakacs Date: Thu, 18 Jan 2024 16:38:47 +0100 Subject: [PATCH 023/136] 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 459d854bf1..0e66565ed0 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. /// From 21ae7a1317255acd0a031e74575f6c08967ce5f3 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Thu, 18 Jan 2024 23:11:22 +0000 Subject: [PATCH 024/136] Added ffmpeg version to build --- .../devcontainer.json | 0 .../Dev - Server Ffmpeg/devcontainer.json | 28 +++++++++++++++++++ .../Dev - Server Ffmpeg/install-ffmpeg.sh | 5 ++++ .vscode/extensions.json | 2 +- 4 files changed, 34 insertions(+), 1 deletion(-) rename .devcontainer/{ => Dev - Server Default}/devcontainer.json (100%) create mode 100644 .devcontainer/Dev - Server Ffmpeg/devcontainer.json create mode 100644 .devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/Dev - Server Default/devcontainer.json similarity index 100% rename from .devcontainer/devcontainer.json rename to .devcontainer/Dev - Server Default/devcontainer.json diff --git a/.devcontainer/Dev - Server Ffmpeg/devcontainer.json b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json new file mode 100644 index 0000000000..8d1413d092 --- /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 0000000000..d2a54b98f3 --- /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/.vscode/extensions.json b/.vscode/extensions.json index d738e9fba4..3be946e446 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" ], From 23c77706838aa2e5cf83ee036f3853a90acc4a68 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 19 Jan 2024 00:48:03 +0000 Subject: [PATCH 025/136] Fixed ffmpeg version updated lauch with ffmpeg --- .devcontainer/Dev - Server Ffmpeg/devcontainer.json | 2 +- .devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh | 6 ++++-- .../{Dev - Server Default => }/devcontainer.json | 0 .vscode/launch.json | 12 ++++++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) rename .devcontainer/{Dev - Server Default => }/devcontainer.json (100%) diff --git a/.devcontainer/Dev - Server Ffmpeg/devcontainer.json b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json index 8d1413d092..0b848d9f3c 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 d2a54b98f3..c84e1258fa 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/Dev - Server Default/devcontainer.json b/.devcontainer/devcontainer.json similarity index 100% rename from .devcontainer/Dev - Server Default/devcontainer.json rename to .devcontainer/devcontainer.json diff --git a/.vscode/launch.json b/.vscode/launch.json index be55764fd4..2673973dbf 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", From 8de70e381485e20378538a58dd7476f10cbab60a Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 19 Jan 2024 06:02:24 +0000 Subject: [PATCH 026/136] Added documentation --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 62ef21334d..3f6ccb7b7c 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:** 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. -#### 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:** 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. From 6ef110b00803e8dad37375d80c98a767859f6c1b Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 19 Jan 2024 06:02:39 +0000 Subject: [PATCH 027/136] 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 c84e1258fa..c867ef538c 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: [PATCH 028/136] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3f6ccb7b7c..878e335ed1 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 From 38bf59d6e8485c05b6b877043b6e87ff09e80d45 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 19 Jan 2024 12:25:39 +0100 Subject: [PATCH 029/136] 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 2673973dbf..7e50d4f0a4 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, From 538f141b4cd7c3537f4a60060d54c51f7e19afcf Mon Sep 17 00:00:00 2001 From: TelepathicWalrus Date: Fri, 19 Jan 2024 17:25:57 +0000 Subject: [PATCH 030/136] 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 d41845cdf0..5f54c73190 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"); } } From 1d235205ae0a53fd7e584b6561dd0f0c6437f691 Mon Sep 17 00:00:00 2001 From: TelepathicWalrus Date: Mon, 22 Jan 2024 17:43:35 +0000 Subject: [PATCH 031/136] 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 5f54c73190..d6530df2dd 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) { From 179965e7749794513be0b832e9d8fb31444e9779 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Tue, 23 Jan 2024 12:11:13 +0100 Subject: [PATCH 032/136] 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 fe251341a9..6e257c0aca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - + 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: [PATCH 033/136] 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 e43160562f..e8168470bd 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 75b6a73e56..386f8d321b 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 }} 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: [PATCH 034/136] 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 e43160562f..7ea3bc3bf4 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 }} From 604f4b2742416abf3149e95d8168b538ecb8b5f1 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 24 Jan 2024 11:17:45 -0500 Subject: [PATCH 035/136] Log SchedulesDirect response on request error --- .../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 5728146f78..eaf5495c7f 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) 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: [PATCH 036/136] 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 d8c550e704..e92a404d24 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 From 9323390add9fe23d2e5b71826b59360f9604f086 Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Sun, 28 Jan 2024 19:40:49 +0800 Subject: [PATCH 037/136] 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 400e7f40fb..cffc014e87 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 629c300603..b532f9a7e3 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" From 92c0ec0c1bc6c25d2dd9e531fcc26a13883bea8a Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Sun, 28 Jan 2024 19:29:23 +0800 Subject: [PATCH 038/136] 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 cffc014e87..2a2614e4d7 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"); 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: [PATCH 039/136] 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 dcf1834949..c88b9e8088 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -71,7 +71,7 @@ - + From d9b911ce7f401d325530335038ef494c45660faa Mon Sep 17 00:00:00 2001 From: azam Date: Sun, 28 Jan 2024 05:53:28 +0000 Subject: [PATCH 040/136] 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 a07222975b..ebd3f7560b 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" } From 2b03927e0e9e9632af3384f237c34156ec4b7144 Mon Sep 17 00:00:00 2001 From: hoanghuy309 Date: Fri, 26 Jan 2024 03:02:35 +0000 Subject: [PATCH 041/136] 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 44ce4ac5b2..e92752c5f7 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." } From 73a9bd1ae59260e54f7a4531f4a7ebd83b6a764a Mon Sep 17 00:00:00 2001 From: antti202 Date: Fri, 26 Jan 2024 18:45:46 +0000 Subject: [PATCH 042/136] 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 081462407d..c78ffa28c3 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." } 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: [PATCH 043/136] 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 c88b9e8088..d1298d9e50 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + From fd116e76160e10585958049867f443a7879c42e2 Mon Sep 17 00:00:00 2001 From: LesDomen Date: Mon, 29 Jan 2024 10:23:01 +0000 Subject: [PATCH 044/136] 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 1944e072cb..110af11b71 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." } From ce81e2aeab942538a7d5640b7ad88a50398b10d5 Mon Sep 17 00:00:00 2001 From: Attila Szakacs Date: Thu, 18 Jan 2024 17:00:00 +0100 Subject: [PATCH 045/136] 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 457f59e0f6..5dcb6daa39 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 0e66565ed0..8fd1f9fc1e 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); } } 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: [PATCH 046/136] 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 d1298d9e50..8b4813f566 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + From 7d6a03bad6b8baacb83625be32e708090722fe10 Mon Sep 17 00:00:00 2001 From: TelepathicWalrus Date: Thu, 1 Feb 2024 07:14:25 +0000 Subject: [PATCH 047/136] 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 d6530df2dd..d4aeae41a5 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) { From d423efd2eac8bb3d392b9d6c98b172cc05b64a5a Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Wed, 20 Dec 2023 13:56:28 +0800 Subject: [PATCH 048/136] 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 f5753467a1..058875cd3b 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 } } From 52da00c3c7ba30d04feacb3956b4df0bbc08c76b Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Wed, 20 Dec 2023 13:58:09 +0800 Subject: [PATCH 049/136] 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 0d1d27ae8b..fdca283908 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 From e62dab627e7eab650d594ca9ca9236e504863bbe Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Mon, 29 Jan 2024 19:46:17 +0800 Subject: [PATCH 050/136] 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 2a2614e4d7..1c95192f18 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. /// From 7f14c14bf60683676ad4991066a9f8931a994376 Mon Sep 17 00:00:00 2001 From: felix920506 Date: Thu, 1 Feb 2024 20:29:06 -0500 Subject: [PATCH 051/136] 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 5878028330..da49a7d4bb 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: 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: [PATCH 052/136] 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 e92a404d24..839bebb96a 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 From efd024bafecd132d7b2f94839e19847411cbf273 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:02:12 -0500 Subject: [PATCH 053/136] 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 5870fed761..84189f7f51 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -695,7 +695,7 @@ namespace Emby.Server.Implementations GetExports(), GetExports()); - Resolve().AddParts(GetExports(), GetExports()); + Resolve().AddParts(GetExports()); Resolve().AddParts(GetExports()); } diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 2dbc2cf82e..69daa5c208 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -71,8 +71,7 @@ namespace MediaBrowser.Controller.LiveTv /// Adds the parts. /// /// The services. - /// The listing providers. - void AddParts(IEnumerable services, IEnumerable listingProviders); + void AddParts(IEnumerable services); /// /// Gets the timer. diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index 21dab69e05..eb97ef3ee7 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Jellyfin.LiveTv.Channels; using Jellyfin.LiveTv.Guide; +using Jellyfin.LiveTv.Listings; using Jellyfin.LiveTv.TunerHosts; using Jellyfin.LiveTv.TunerHosts.HdHomerun; using MediaBrowser.Controller.Channels; @@ -29,5 +30,7 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index aa3be2048a..1595c8553e 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -47,9 +47,9 @@ namespace Jellyfin.LiveTv private readonly ILocalizationManager _localization; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; + private readonly IListingsProvider[] _listingProviders; private ILiveTvService[] _services = Array.Empty(); - private IListingsProvider[] _listingProviders = Array.Empty(); public LiveTvManager( IServerConfigurationManager config, @@ -61,7 +61,8 @@ namespace Jellyfin.LiveTv ITaskManager taskManager, ILocalizationManager localization, IChannelManager channelManager, - LiveTvDtoService liveTvDtoService) + LiveTvDtoService liveTvDtoService, + IEnumerable listingProviders) { _config = config; _logger = logger; @@ -73,6 +74,7 @@ namespace Jellyfin.LiveTv _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; + _listingProviders = listingProviders.ToArray(); } public event EventHandler> SeriesTimerCancelled; @@ -97,12 +99,10 @@ namespace Jellyfin.LiveTv } /// - public void AddParts(IEnumerable services, IEnumerable listingProviders) + public void AddParts(IEnumerable services) { _services = services.ToArray(); - _listingProviders = listingProviders.ToArray(); - foreach (var service in _services) { if (service is EmbyTV.EmbyTV embyTv) From 775b7eadef0e36f88f9b4424ac3cd924406b38ca Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:26:47 -0500 Subject: [PATCH 054/136] 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 e7e927b2d0..ce55d74271 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 1595c8553e..19a71a119a 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); From 34269dee581b095fe63251aa0ffc1360375c989b Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 12:35:48 -0500 Subject: [PATCH 055/136] Use DI for ILiveTvService --- .../ApplicationHost.cs | 2 -- .../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 84189f7f51..d268a6ba84 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -695,8 +695,6 @@ namespace Emby.Server.Implementations GetExports(), GetExports()); - Resolve().AddParts(GetExports()); - Resolve().AddParts(GetExports()); } diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 69daa5c208..7da455b8d4 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -67,12 +67,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task. Task CancelSeriesTimer(string id); - /// - /// Adds the parts. - /// - /// The services. - void AddParts(IEnumerable services); - /// /// Gets the timer. /// diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index eb97ef3ee7..a07325ad18 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 19a71a119a..ef5283b980 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -47,10 +47,9 @@ namespace Jellyfin.LiveTv private readonly ILocalizationManager _localization; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; + private readonly ILiveTvService[] _services; private readonly IListingsProvider[] _listingProviders; - private ILiveTvService[] _services = Array.Empty(); - public LiveTvManager( IServerConfigurationManager config, ILogger logger, @@ -62,6 +61,7 @@ namespace Jellyfin.LiveTv ILocalizationManager localization, IChannelManager channelManager, LiveTvDtoService liveTvDtoService, + IEnumerable services, IEnumerable listingProviders) { _config = config; @@ -74,7 +74,12 @@ namespace Jellyfin.LiveTv _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; + _services = services.ToArray(); _listingProviders = listingProviders.ToArray(); + + var defaultService = _services.OfType().First(); + defaultService.TimerCreated += OnEmbyTvTimerCreated; + defaultService.TimerCancelled += OnEmbyTvTimerCancelled; } public event EventHandler> SeriesTimerCancelled; @@ -98,21 +103,6 @@ namespace Jellyfin.LiveTv return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); } - /// - public void AddParts(IEnumerable services) - { - _services = services.ToArray(); - - foreach (var service in _services) - { - if (service is EmbyTV.EmbyTV embyTv) - { - embyTv.TimerCreated += OnEmbyTvTimerCreated; - embyTv.TimerCancelled += OnEmbyTvTimerCancelled; - } - } - } - private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs e) { var timerId = e.Argument; From e4f715bbee26985ae7666e4c80282ac52e7fce73 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sat, 3 Feb 2024 00:26:49 -0500 Subject: [PATCH 056/136] 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 0000000000..0967ef424b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ky.json @@ -0,0 +1 @@ +{} From dba043ef488310ab9db9ff7b29aedbfb790e3daa Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 3 Feb 2024 08:52:26 +0100 Subject: [PATCH 057/136] 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 7c0169c6ef..3c9c5d9c1b 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 + From a32fe89dad7f0d654ab5846bef6f9a52385cda1a Mon Sep 17 00:00:00 2001 From: Jake Date: Sat, 3 Feb 2024 08:25:38 -0700 Subject: [PATCH 058/136] 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 878e335ed1..ec065f2603 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. From 918b627472058c5c7456dfade442817265dabb8c Mon Sep 17 00:00:00 2001 From: beakerandjake Date: Sat, 3 Feb 2024 14:25:44 -0700 Subject: [PATCH 059/136] 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 3d4df03869..6c5ce47158 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; 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: [PATCH 060/136] 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 0dacbc5c61..4b5db14aef 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/" From d82d025b248cbd8eee8372dfc08940d53675d4ee Mon Sep 17 00:00:00 2001 From: beakerandjake Date: Sat, 3 Feb 2024 19:10:08 -0700 Subject: [PATCH 061/136] 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 0000000000..dd84c1a186 --- /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); + } + } +} From a1667230746e4f301728f88698ddedf498251fbc Mon Sep 17 00:00:00 2001 From: felix920506 Date: Mon, 5 Feb 2024 01:13:06 -0500 Subject: [PATCH 062/136] 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 da49a7d4bb..b690b82c24 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: From be265cd87f2d65c99c6a1d7d128dc4391724939e Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Mon, 5 Feb 2024 23:41:43 +0800 Subject: [PATCH 063/136] 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 fb4e7bd1f5..29dd190ab7 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 17813559a8..f2a0b906dc 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 da683a17e6..e6b7f4d9b3 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) 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: [PATCH 064/136] 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 17fe0400a0..97f1a33e76 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 From 34a89fdefd47d4007d5222af9f71fbf7c3a7a1b8 Mon Sep 17 00:00:00 2001 From: Soumendra kumar sahoo Date: Tue, 6 Feb 2024 09:09:32 +0000 Subject: [PATCH 065/136] 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 0e9d81ee87..8251c12907 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": "ଫୋଲ୍ଡରଗୁଡ଼ିକ" } From 5cc451992b29fd03f281bdeddce370c9ff87f82e Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Tue, 6 Feb 2024 20:36:15 +0800 Subject: [PATCH 066/136] 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 fa81fc284d..b0c17c8356 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 From 584636bdd8ea95d56b3c1cda97ce6efa8ce1543c Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 09:37:02 -0500 Subject: [PATCH 067/136] 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 eaf5495c7f..64b64c0aeb 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); From 8698b905947860ed59db1634e3765d78217d362d Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 09:50:46 -0500 Subject: [PATCH 068/136] Remove SimpleProgress --- .../Library/LibraryManager.cs | 6 +++--- .../ScheduledTasks/ScheduledTaskWorker.cs | 3 +-- Jellyfin.Api/Controllers/LibraryController.cs | 4 +--- .../Controllers/LibraryStructureController.cs | 8 +++----- MediaBrowser.Common/Progress/SimpleProgress.cs | 17 ----------------- MediaBrowser.Controller/Channels/Channel.cs | 3 +-- MediaBrowser.Controller/Entities/Folder.cs | 2 +- .../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 8ae913dad8..851581a4a6 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 1af2c96d2f..efb6436ae9 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 a0bbc961f0..e357588d1d 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 d483ca4d2b..23c430f859 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 7071f2bc3d..0000000000 --- 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 94418683b2..f186523b9a 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 74eb089de3..4f066d4158 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 b530b9de3f..2e9547bf31 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 a7de5c65b1..1948a9ab9b 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 556e052d4e..79c5873d51 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 a9642bb60b..39f334184b 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); } } From 096043806581d305f1d56cf265183e70e2c81e49 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 09:58:25 -0500 Subject: [PATCH 069/136] Remove ActionableProgress --- .../Library/LibraryManager.cs | 13 ++----- .../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 851581a4a6..7998ce34a7 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 0ba46ea3ba..0000000000 --- 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 4f066d4158..e9ff1f1a50 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 bfbc6d4cc6..394fbbaeab 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); From a54c08209e467dfafe924cc6acb691deb2daa428 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 10:06:39 -0500 Subject: [PATCH 070/136] 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 044ba6d331..0000000000 --- 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 841e7b2872..0000000000 --- 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 - { - } -} From 4dd2ed8fb7bbd825995e7ec4aead11d6d9728a19 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 10:09:51 -0500 Subject: [PATCH 071/136] Remove some unused drawing code --- .../Drawing/ImageStream.cs | 42 ------------------ .../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 f4c3057993..0000000000 --- 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 581fa000dc..0000000000 --- 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 5721e28820..4ae5a9a483 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 d0e69d42c8..0000000000 --- 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) - { - } -} From 6d8062116cc89a66a66352a8ed44cfad2f3a00ba Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 10:15:29 -0500 Subject: [PATCH 072/136] Remove some unused model code --- .../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 21087b5647..0000000000 --- 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 06cc3e73cf..0000000000 --- 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 2250c5dffb..0000000000 --- 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 1524786ea7..0000000000 --- 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; } - } -} From 505c09c85b9816519c795c114e6100585b35e249 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 11:49:51 -0500 Subject: [PATCH 073/136] 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 e9ff1f1a50..1f13c833b6 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"); + } } }); 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: [PATCH 074/136] 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 3c9c5d9c1b..4a236e5b64 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,7 +48,7 @@ - + From be29b4a0c4e0a525da0a8537810d11586cec9b67 Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Tue, 6 Feb 2024 22:16:41 +0100 Subject: [PATCH 075/136] Fix some incompatible API deprecations --- .../SessionDtos/ClientCapabilitiesDto.cs | 13 +++++++++ .../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 acd3f29e34..12ce19368b 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 597845fc17..5f51fb21c3 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 aa7c03ebd5..f37ac6a147 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"; } } From 99ea6059c7493ac4ee65980abe631df4969112e9 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 14:45:44 -0500 Subject: [PATCH 076/136] Use IHostedService for UPnP port forwarding --- Jellyfin.Server/Startup.cs | 1 + ...ortForwarding.cs => PortForwardingHost.cs} | 71 +++++++++---------- 2 files changed, 35 insertions(+), 37 deletions(-) rename src/Jellyfin.Networking/{ExternalPortForwarding.cs => PortForwardingHost.cs} (76%) diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 7d5f22545d..7cf7d75da0 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/PortForwardingHost.cs similarity index 76% rename from src/Jellyfin.Networking/ExternalPortForwarding.cs rename to src/Jellyfin.Networking/PortForwardingHost.cs index df9e43ca90..d01343624e 100644 --- a/src/Jellyfin.Networking/ExternalPortForwarding.cs +++ b/src/Jellyfin.Networking/PortForwardingHost.cs @@ -1,7 +1,3 @@ -#nullable disable - -#pragma warning disable CS1591 - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -12,36 +8,34 @@ using System.Threading.Tasks; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Mono.Nat; namespace Jellyfin.Networking; /// -/// Server entrypoint handling external port forwarding. +/// responsible for UPnP port forwarding. /// -public sealed class ExternalPortForwarding : IServerEntryPoint +public sealed class PortForwardingHost : IHostedService, IDisposable { private readonly IServerApplicationHost _appHost; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IServerConfigurationManager _config; + private readonly ConcurrentDictionary _createdRules = new(); - private readonly ConcurrentDictionary _createdRules = new ConcurrentDictionary(); - - private Timer _timer; - private string _configIdentifier; - + private Timer? _timer; + private string? _configIdentifier; private bool _disposed; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The logger. /// The application host. /// The configuration manager. - public ExternalPortForwarding( - ILogger logger, + public PortForwardingHost( + ILogger logger, IServerApplicationHost appHost, IServerConfigurationManager config) { @@ -66,7 +60,7 @@ public sealed class ExternalPortForwarding : IServerEntryPoint .ToString(); } - private void OnConfigurationUpdated(object sender, EventArgs e) + private void OnConfigurationUpdated(object? sender, EventArgs e) { var oldConfigIdentifier = _configIdentifier; _configIdentifier = GetConfigIdentifier(); @@ -79,7 +73,7 @@ public sealed class ExternalPortForwarding : IServerEntryPoint } /// - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) { Start(); @@ -88,6 +82,14 @@ public sealed class ExternalPortForwarding : IServerEntryPoint return Task.CompletedTask; } + /// + public Task StopAsync(CancellationToken cancellationToken) + { + Stop(); + + return Task.CompletedTask; + } + private void Start() { var config = _config.GetNetworkConfiguration(); @@ -101,7 +103,8 @@ public sealed class ExternalPortForwarding : IServerEntryPoint NatUtility.DeviceFound += OnNatUtilityDeviceFound; NatUtility.StartDiscovery(); - _timer = new Timer((_) => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); + _timer?.Dispose(); + _timer = new Timer(_ => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); } private void Stop() @@ -112,13 +115,23 @@ public sealed class ExternalPortForwarding : IServerEntryPoint NatUtility.DeviceFound -= OnNatUtilityDeviceFound; _timer?.Dispose(); + _timer = null; } - private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e) + private async void OnNatUtilityDeviceFound(object? sender, DeviceEventArgs e) { + ObjectDisposedException.ThrowIf(_disposed, this); + try { - await CreateRules(e.Device).ConfigureAwait(false); + // 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) { @@ -126,20 +139,6 @@ public sealed class ExternalPortForwarding : IServerEntryPoint } } - 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(); @@ -185,8 +184,6 @@ public sealed class ExternalPortForwarding : IServerEntryPoint _config.ConfigurationUpdated -= OnConfigurationUpdated; - Stop(); - _timer?.Dispose(); _timer = null; From d986a824cde349e2e4d7e0bff34356ff364d5e74 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 15:35:13 -0500 Subject: [PATCH 077/136] 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 a471ea1d50..0000000000 --- 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 0000000000..e40b541a35 --- /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); + } + } + } +} From 4e02d8aa21eedc6fe9c1d3ee843db3d5e3858b4c Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 15:40:52 -0500 Subject: [PATCH 078/136] 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 83e7b230df..4c668379c8 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 7cf7d75da0..bb5513f865 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(); } /// From 9e62b6919ffc7c24b66300279c2fd3c4a0c7f5bd Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 15:54:03 -0500 Subject: [PATCH 079/136] 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 d32759017d..957ad9c01b 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 bb5513f865..b0bb182aa6 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(); } /// From 24b4d025967135a8895fedf1c45f3679f3b89393 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:08:41 -0500 Subject: [PATCH 080/136] 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 b0bb182aa6..84f9bff617 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 2923948ebf..226d525e71 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; + + return Task.CompletedTask; } - private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs e) - { - await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); - } + private async void OnLiveTvManagerSeriesTimerCreated(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); - private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs e) - { - await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); - } + private async void OnLiveTvManagerTimerCreated(object? sender, GenericEventArgs e) + => await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); - private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs e) - { - await SendMessage(SessionMessageType.TimerCancelled, 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; - } } } From 690e603b90ae9d856386d5fb6bf3e32d4cb46a9c Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:18:17 -0500 Subject: [PATCH 081/136] 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 84f9bff617..1d78b1602f 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 a6216ef305..0000000000 --- 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 0000000000..b2882194dd --- /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); + } + } +} From c9311c9e7e048eadb1abb62a1904098adb740a76 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:23:18 -0500 Subject: [PATCH 082/136] 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 1d78b1602f..558ad5b7bd 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 e750c05ac8..0000000000 --- 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 0000000000..dc15d53ffa --- /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; +} From 4c7eca931390f82237273b39cc26381323623180 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:35:38 -0500 Subject: [PATCH 083/136] Use IHostApplicationLifetime to start library monitor --- .../IO/LibraryMonitor.cs | 73 ++++++------------- .../IO/LibraryMonitorStartup.cs | 35 --------- .../Library/ILibraryMonitor.cs | 9 +-- 3 files changed, 25 insertions(+), 92 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 dde38906f3..31617d1a5f 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; + + appLifetime.ApplicationStarted.Register(Start); } - /// - /// 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; - } - + /// 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 c51cf05459..0000000000 --- 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 de74aa5a11..6d2f5b8738 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. From 19a72e8bf2b1a68fddb992357577683027408e90 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 6 Feb 2024 16:38:12 -0500 Subject: [PATCH 084/136] Remove IServerEntryPoint --- .../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 d268a6ba84..550c16b4c4 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 2b831103a5..0000000000 --- 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 6024661e15..0000000000 --- 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(); - } -} From 42b052a5a619abf33ceeb4bc4aafcc1d3d52a723 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 17 Jan 2024 15:18:15 -0500 Subject: [PATCH 085/136] Add IListingsManager service --- Jellyfin.Api/Controllers/LiveTvController.cs | 50 +- .../LiveTv/IListingsManager.cs | 79 +++ .../LiveTv/ILiveTvManager.cs | 31 -- .../LiveTv/TunerChannelMapping.cs | 17 - .../LiveTv}/ChannelMappingOptionsDto.cs | 3 +- .../LiveTv/TunerChannelMapping.cs | 16 + src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 285 +---------- .../LiveTvServiceCollectionExtensions.cs | 1 + .../Listings/ListingsManager.cs | 470 ++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 168 +------ 10 files changed, 590 insertions(+), 530 deletions(-) create mode 100644 MediaBrowser.Controller/LiveTv/IListingsManager.cs delete mode 100644 MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs rename {Jellyfin.Api/Models/LiveTvDtos => MediaBrowser.Model/LiveTv}/ChannelMappingOptionsDto.cs (90%) 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 da68c72c99..7f4cad951e 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -45,6 +45,7 @@ public class LiveTvController : BaseJellyfinApiController private readonly ILiveTvManager _liveTvManager; private readonly IGuideManager _guideManager; private readonly ITunerHostManager _tunerHostManager; + private readonly IListingsManager _listingsManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; @@ -59,6 +60,7 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -70,6 +72,7 @@ public class LiveTvController : BaseJellyfinApiController ILiveTvManager liveTvManager, IGuideManager guideManager, ITunerHostManager tunerHostManager, + IListingsManager listingsManager, IUserManager userManager, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, @@ -81,6 +84,7 @@ public class LiveTvController : BaseJellyfinApiController _liveTvManager = liveTvManager; _guideManager = guideManager; _tunerHostManager = tunerHostManager; + _listingsManager = listingsManager; _userManager = userManager; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; @@ -1015,7 +1019,7 @@ public class LiveTvController : BaseJellyfinApiController listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); } - return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); } /// @@ -1029,7 +1033,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DeleteListingProvider([FromQuery] string? id) { - _liveTvManager.DeleteListingsProvider(id); + _listingsManager.DeleteListingsProvider(id); return NoContent(); } @@ -1050,9 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] string? type, [FromQuery] string? location, [FromQuery] string? country) - { - return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); - } + => await _listingsManager.GetLineups(type, id, country, location).ConfigureAwait(false); /// /// Gets available countries. @@ -1083,48 +1085,20 @@ public class LiveTvController : BaseJellyfinApiController [HttpGet("ChannelMappingOptions")] [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetChannelMappingOptions([FromQuery] string? providerId) - { - var config = _configurationManager.GetConfiguration("livetv"); - - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - - var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; - - var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var mappings = listingsProviderInfo.ChannelMappings; - - return new ChannelMappingOptionsDto - { - TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), - ProviderChannels = providerChannels.Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Id - }).ToList(), - Mappings = mappings, - ProviderName = listingsProviderName - }; - } + public Task GetChannelMappingOptions([FromQuery] string? providerId) + => _listingsManager.GetChannelMappingOptions(providerId); /// /// Set channel mappings. /// - /// The set channel mapping dto. + /// The set channel mapping dto. /// Created channel mapping returned. /// An containing the created channel mapping. [HttpPost("ChannelMappings")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) - { - return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); - } + public Task SetChannelMapping([FromBody, Required] SetChannelMappingDto dto) + => _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId); /// /// Get tuner host types. diff --git a/MediaBrowser.Controller/LiveTv/IListingsManager.cs b/MediaBrowser.Controller/LiveTv/IListingsManager.cs new file mode 100644 index 0000000000..bbf569575a --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/IListingsManager.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; + +namespace MediaBrowser.Controller.LiveTv; + +/// +/// Service responsible for managing s and mapping +/// their channels to channels provided by s. +/// +public interface IListingsManager +{ + /// + /// Saves the listing provider. + /// + /// The listing provider information. + /// A value indicating whether to validate login. + /// A value indicating whether to validate listings.. + /// Task. + Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings); + + /// + /// Deletes the listing provider. + /// + /// The listing provider's id. + void DeleteListingsProvider(string? id); + + /// + /// Gets the lineups. + /// + /// Type of the provider. + /// The provider identifier. + /// The country. + /// The location. + /// The available lineups. + Task> GetLineups(string? providerType, string? providerId, string? country, string? location); + + /// + /// Gets the programs for a provided channel. + /// + /// The channel to retrieve programs for. + /// The earliest date to retrieve programs for. + /// The latest date to retrieve programs for. + /// The to use. + /// The available programs. + Task> GetProgramsAsync( + ChannelInfo channel, + DateTime startDateUtc, + DateTime endDateUtc, + CancellationToken cancellationToken); + + /// + /// Adds metadata from the s to the provided channels. + /// + /// The channels. + /// A value indicating whether to use the EPG channel cache. + /// The to use. + /// A task representing the metadata population. + Task AddProviderMetadata(IList channels, bool enableCache, CancellationToken cancellationToken); + + /// + /// Gets the channel mapping options for a provider. + /// + /// The id of the provider to use. + /// The channel mapping options. + Task GetChannelMappingOptions(string? providerId); + + /// + /// Sets the channel mapping. + /// + /// The id of the provider for the mapping. + /// The tuner channel number. + /// The provider channel number. + /// The updated channel mapping. + Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber); +} diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 7da455b8d4..0ac0699a37 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -36,8 +36,6 @@ namespace MediaBrowser.Controller.LiveTv /// The services. IReadOnlyList Services { get; } - IReadOnlyList ListingProviders { get; } - /// /// Gets the new timer defaults asynchronous. /// @@ -239,31 +237,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task. Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem Item, BaseItemDto ItemDto)> programs, IReadOnlyList fields, User user = null); - /// - /// Saves the listing provider. - /// - /// The information. - /// if set to true [validate login]. - /// if set to true [validate listings]. - /// Task. - Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings); - - void DeleteListingsProvider(string id); - - Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber); - - TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List providerChannels); - - /// - /// Gets the lineups. - /// - /// Type of the provider. - /// The provider identifier. - /// The country. - /// The location. - /// Task<List<NameIdPair>>. - Task> GetLineups(string providerType, string providerId, string country, string location); - /// /// Adds the channel information. /// @@ -272,10 +245,6 @@ namespace MediaBrowser.Controller.LiveTv /// The user. void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user); - Task> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken); - - Task> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken); - string GetEmbyTvActiveRecordingPath(string id); ActiveRecordingInfo GetActiveRecordingInfo(string path); diff --git a/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs b/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs deleted file mode 100644 index 1c1a4417dc..0000000000 --- a/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs +++ /dev/null @@ -1,17 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -namespace MediaBrowser.Controller.LiveTv -{ - public class TunerChannelMapping - { - public string Name { get; set; } - - public string ProviderChannelName { get; set; } - - public string ProviderChannelId { get; set; } - - public string Id { get; set; } - } -} diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs similarity index 90% rename from Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs rename to MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs index cbc3548b10..3f9ecc8c86 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs +++ b/MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; -namespace Jellyfin.Api.Models.LiveTvDtos; +namespace MediaBrowser.Model.LiveTv; /// /// Channel mapping options dto. diff --git a/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs b/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs new file mode 100644 index 0000000000..647e24a913 --- /dev/null +++ b/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs @@ -0,0 +1,16 @@ +#nullable disable + +#pragma warning disable CS1591 + +namespace MediaBrowser.Model.LiveTv; + +public class TunerChannelMapping +{ + public string Name { get; set; } + + public string ProviderChannelName { get; set; } + + public string ProviderChannelId { get; set; } + + public string Id { get; set; } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 39f334184b..e19d2c5911 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -61,14 +61,11 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly IMediaSourceManager _mediaSourceManager; private readonly IStreamHelper _streamHelper; private readonly LiveTvDtoService _tvDtoService; - private readonly IListingsProvider[] _listingsProviders; + private readonly IListingsManager _listingsManager; private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _epgChannels = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1); private bool _disposed; @@ -86,7 +83,7 @@ namespace Jellyfin.LiveTv.EmbyTV IProviderManager providerManager, IMediaEncoder mediaEncoder, LiveTvDtoService tvDtoService, - IEnumerable listingsProviders) + IListingsManager listingsManager) { Current = this; @@ -102,7 +99,7 @@ namespace Jellyfin.LiveTv.EmbyTV _tunerHostManager = tunerHostManager; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; - _listingsProviders = listingsProviders.ToArray(); + _listingsManager = listingsManager; _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); @@ -312,15 +309,15 @@ namespace Jellyfin.LiveTv.EmbyTV private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) { - var list = new List(); + var channels = new List(); foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { - var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); + var tunerChannels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); - list.AddRange(channels); + channels.AddRange(tunerChannels); } catch (Exception ex) { @@ -328,209 +325,9 @@ namespace Jellyfin.LiveTv.EmbyTV } } - foreach (var provider in GetListingProviders()) - { - var enabledChannels = list - .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId)) - .ToList(); + await _listingsManager.AddProviderMetadata(channels, enableCache, cancellationToken).ConfigureAwait(false); - 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)); - } - - private ChannelInfo GetEpgChannelFromTunerChannel(ListingsProviderInfo info, ChannelInfo tunerChannel, EpgChannelData epgChannels) - { - return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); - } - - private ChannelInfo GetEpgChannelFromTunerChannel( - NameValuePair[] mappings, - ChannelInfo tunerChannel, - EpgChannelData epgChannelData) - { - if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) - { - var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings); - - if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) - { - mappedTunerChannelId = tunerChannel.Id; - } - - var channel = epgChannelData.GetChannelById(mappedTunerChannelId); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) - { - var tunerChannelId = tunerChannel.TunerChannelId; - if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) - { - tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); - } - - var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings); - - if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) - { - mappedTunerChannelId = tunerChannelId; - } - - var channel = epgChannelData.GetChannelById(mappedTunerChannelId); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) - { - var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings); - - if (string.IsNullOrWhiteSpace(tunerChannelNumber)) - { - tunerChannelNumber = tunerChannel.Number; - } - - var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) - { - var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name); - - var channel = epgChannelData.GetChannelByName(normalizedName); - - if (channel is not null) - { - return channel; - } - } - - return null; - } - - public async Task> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) - { - var list = new List(); - - foreach (var hostInstance in _tunerHostManager.TunerHosts) - { - try - { - var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); - - list.AddRange(channels); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channels"); - } - } - - return list - .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId)) - .ToList(); + return channels; } public Task> GetChannelsAsync(CancellationToken cancellationToken) @@ -877,75 +674,13 @@ namespace Jellyfin.LiveTv.EmbyTV return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); } - private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) - { - if (info.EnableAllTuners) - { - return true; - } - - if (string.IsNullOrWhiteSpace(tunerHostId)) - { - throw new ArgumentNullException(nameof(tunerHostId)); - } - - return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase); - } - public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) { var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); - foreach (var provider in GetListingProviders()) - { - if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId)) - { - _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - continue; - } - - _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - - var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); - - if (epgChannel is null) - { - _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - continue; - } - - List programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) - .ConfigureAwait(false)).ToList(); - - // Replace the value that came from the provider with a normalized value - foreach (var program in programs) - { - program.ChannelId = channelId; - - program.Id += "_" + channelId; - } - - if (programs.Count > 0) - { - return programs; - } - } - - return Enumerable.Empty(); - } - - private List> GetListingProviders() - { - return _config.GetLiveTvConfiguration().ListingProviders - .Select(i => - { - var provider = _listingsProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - return provider is null ? null : new Tuple(provider, i); - }) - .Where(i => i is not null) - .ToList(); + return await _listingsManager.GetProgramsAsync(channel, startDateUtc, endDateUtc, cancellationToken) + .ConfigureAwait(false); } public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index a07325ad18..e4800a0312 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs new file mode 100644 index 0000000000..113979257d --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.EmbyTV; +using Jellyfin.LiveTv.Guide; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Listings; + +/// +public class ListingsManager : IListingsManager +{ + private readonly ILogger _logger; + private readonly IConfigurationManager _config; + private readonly ITaskManager _taskManager; + private readonly ITunerHostManager _tunerHostManager; + private readonly IListingsProvider[] _listingsProviders; + + private readonly ConcurrentDictionary _epgChannels = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + /// The . + public ListingsManager( + ILogger logger, + IConfigurationManager config, + ITaskManager taskManager, + ITunerHostManager tunerHostManager, + IEnumerable listingsProviders) + { + _logger = logger; + _config = config; + _taskManager = taskManager; + _tunerHostManager = tunerHostManager; + _listingsProviders = listingsProviders.ToArray(); + } + + /// + public async Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + ArgumentNullException.ThrowIfNull(info); + + // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider + // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider + info = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(info))!; + + var provider = GetProvider(info.Type); + await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false); + + var config = _config.GetLiveTvConfiguration(); + + var list = config.ListingProviders.ToList(); + int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) + { + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + list.Add(info); + config.ListingProviders = list.ToArray(); + } + else + { + config.ListingProviders[index] = info; + } + + _config.SaveConfiguration("livetv", config); + _taskManager.CancelIfRunningAndQueue(); + + return info; + } + + /// + public void DeleteListingsProvider(string? id) + { + var config = _config.GetLiveTvConfiguration(); + + config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); + + _config.SaveConfiguration("livetv", config); + _taskManager.CancelIfRunningAndQueue(); + } + + /// + public Task> GetLineups(string? providerType, string? providerId, string? country, string? location) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return GetProvider(providerType).GetLineups(null, country, location); + } + + var info = _config.GetLiveTvConfiguration().ListingProviders + .FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase)) + ?? throw new ResourceNotFoundException(); + + return GetProvider(info.Type).GetLineups(info, country, location); + } + + /// + public async Task> GetProgramsAsync( + ChannelInfo channel, + DateTime startDateUtc, + DateTime endDateUtc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(channel); + + foreach (var (provider, providerInfo) in GetListingProviders()) + { + if (!IsListingProviderEnabledForTuner(providerInfo, channel.TunerHostId)) + { + _logger.LogDebug( + "Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", + channel.Number, + channel.Name, + provider.Name, + providerInfo.ListingsId ?? string.Empty); + continue; + } + + _logger.LogDebug( + "Getting programs for channel {0}-{1} from {2}-{3}", + channel.Number, + channel.Name, + provider.Name, + providerInfo.ListingsId ?? string.Empty); + + var epgChannels = await GetEpgChannels(provider, providerInfo, true, cancellationToken).ConfigureAwait(false); + + var epgChannel = GetEpgChannelFromTunerChannel(providerInfo.ChannelMappings, channel, epgChannels); + if (epgChannel is null) + { + _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Name, providerInfo.ListingsId ?? string.Empty); + continue; + } + + var programs = (await provider + .GetProgramsAsync(providerInfo, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken).ConfigureAwait(false)) + .ToList(); + + // Replace the value that came from the provider with a normalized value + foreach (var program in programs) + { + program.ChannelId = channel.Id; + program.Id += "_" + channel.Id; + } + + if (programs.Count > 0) + { + return programs; + } + } + + return Enumerable.Empty(); + } + + /// + public async Task AddProviderMetadata(IList channels, bool enableCache, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(channels); + + foreach (var (provider, providerInfo) in GetListingProviders()) + { + var enabledChannels = channels + .Where(i => IsListingProviderEnabledForTuner(providerInfo, i.TunerHostId)) + .ToList(); + + if (enabledChannels.Count == 0) + { + continue; + } + + try + { + await AddMetadata(provider, providerInfo, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false); + } + catch (NotSupportedException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding metadata"); + } + } + } + + /// + public async Task GetChannelMappingOptions(string? providerId) + { + var listingsProviderInfo = _config.GetLiveTvConfiguration().ListingProviders + .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase)); + + var provider = GetProvider(listingsProviderInfo.Type); + + var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None) + .ConfigureAwait(false); + + var providerChannels = await provider.GetChannels(listingsProviderInfo, default) + .ConfigureAwait(false); + + var mappings = listingsProviderInfo.ChannelMappings; + + return new ChannelMappingOptionsDto + { + TunerChannels = tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), + ProviderChannels = providerChannels.Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Id + }).ToList(), + Mappings = mappings, + ProviderName = provider.Name + }; + } + + /// + public async Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) + { + var config = _config.GetLiveTvConfiguration(); + + var listingsProviderInfo = config.ListingProviders + .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase)); + + listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings + .Where(pair => !string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); + + if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) + { + var list = listingsProviderInfo.ChannelMappings.ToList(); + list.Add(new NameValuePair + { + Name = tunerChannelNumber, + Value = providerChannelNumber + }); + listingsProviderInfo.ChannelMappings = list.ToArray(); + } + + _config.SaveConfiguration("livetv", config); + + var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None) + .ConfigureAwait(false); + + var providerChannels = await GetProvider(listingsProviderInfo.Type).GetChannels(listingsProviderInfo, default) + .ConfigureAwait(false); + + var tunerChannelMappings = tunerChannels + .Select(i => GetTunerChannelMapping(i, listingsProviderInfo.ChannelMappings, providerChannels)).ToList(); + + _taskManager.CancelIfRunningAndQueue(); + + return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)); + } + + private List> GetListingProviders() + => _config.GetLiveTvConfiguration().ListingProviders + .Select(i => + { + var provider = _listingsProviders + .FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + return provider is null ? null : new Tuple(provider, i); + }) + .Where(i => i is not null) + .ToList()!; // Already filtered out null + + private async Task AddMetadata( + IListingsProvider provider, + ListingsProviderInfo info, + IEnumerable tunerChannels, + bool enableCache, + CancellationToken cancellationToken) + { + var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); + + foreach (var tunerChannel in tunerChannels) + { + var epgChannel = GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); + if (epgChannel is null) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) + { + tunerChannel.ImageUrl = epgChannel.ImageUrl; + } + } + } + + private static bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) + { + if (info.EnableAllTuners) + { + return true; + } + + ArgumentException.ThrowIfNullOrWhiteSpace(tunerHostId); + + return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase); + } + + private static string GetMappedChannel(string channelId, NameValuePair[] mappings) + { + foreach (NameValuePair mapping in mappings) + { + if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase)) + { + return mapping.Value; + } + } + + return channelId; + } + + private async Task GetEpgChannels( + IListingsProvider provider, + ListingsProviderInfo info, + bool enableCache, + CancellationToken cancellationToken) + { + if (enableCache && _epgChannels.TryGetValue(info.Id, out var result)) + { + return result; + } + + var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false); + foreach (var channel in channels) + { + _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id); + } + + result = new EpgChannelData(channels); + _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result); + + return result; + } + + private static ChannelInfo? GetEpgChannelFromTunerChannel( + NameValuePair[] mappings, + ChannelInfo tunerChannel, + EpgChannelData epgChannelData) + { + if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) + { + var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings); + if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) + { + mappedTunerChannelId = tunerChannel.Id; + } + + var channel = epgChannelData.GetChannelById(mappedTunerChannelId); + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) + { + var tunerChannelId = tunerChannel.TunerChannelId; + if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) + { + tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); + } + + var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings); + if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) + { + mappedTunerChannelId = tunerChannelId; + } + + var channel = epgChannelData.GetChannelById(mappedTunerChannelId); + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) + { + var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings); + if (string.IsNullOrWhiteSpace(tunerChannelNumber)) + { + tunerChannelNumber = tunerChannel.Number; + } + + var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber); + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) + { + var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name); + + var channel = epgChannelData.GetChannelByName(normalizedName); + if (channel is not null) + { + return channel; + } + } + + return null; + } + + private static TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, IList providerChannels) + { + var result = new TunerChannelMapping + { + Name = tunerChannel.Name, + Id = tunerChannel.Id + }; + + if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) + { + result.Name = tunerChannel.Number + " " + result.Name; + } + + var providerChannel = GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(providerChannels)); + if (providerChannel is not null) + { + result.ProviderChannelName = providerChannel.Name; + result.ProviderChannelId = providerChannel.Id; + } + + return result; + } + + private async Task> GetChannelsForListingsProvider(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var channels = new List(); + foreach (var hostInstance in _tunerHostManager.TunerHosts) + { + try + { + var tunerChannels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); + + channels.AddRange(tunerChannels.Where(channel => IsListingProviderEnabledForTuner(info, channel.TunerHostId))); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channels"); + } + } + + return channels; + } + + private IListingsProvider GetProvider(string? providerType) + => _listingsProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase)) + ?? throw new ResourceNotFoundException($"Couldn't find provider of type {providerType}"); +} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index ef5283b980..1b69fd7fdd 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -6,14 +6,12 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.LiveTv.Configuration; -using Jellyfin.LiveTv.Guide; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -27,7 +25,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Jellyfin.LiveTv @@ -43,12 +40,10 @@ namespace Jellyfin.LiveTv private readonly IDtoService _dtoService; private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; - private readonly ITaskManager _taskManager; private readonly ILocalizationManager _localization; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; private readonly ILiveTvService[] _services; - private readonly IListingsProvider[] _listingProviders; public LiveTvManager( IServerConfigurationManager config, @@ -57,25 +52,21 @@ namespace Jellyfin.LiveTv IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, - ITaskManager taskManager, ILocalizationManager localization, IChannelManager channelManager, LiveTvDtoService liveTvDtoService, - IEnumerable services, - IEnumerable listingProviders) + IEnumerable services) { _config = config; _logger = logger; _userManager = userManager; _libraryManager = libraryManager; - _taskManager = taskManager; _localization = localization; _dtoService = dtoService; _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; _services = services.ToArray(); - _listingProviders = listingProviders.ToArray(); var defaultService = _services.OfType().First(); defaultService.TimerCreated += OnEmbyTvTimerCreated; @@ -96,8 +87,6 @@ namespace Jellyfin.LiveTv /// The services. public IReadOnlyList Services => _services; - public IReadOnlyList ListingProviders => _listingProviders; - public string GetEmbyTvActiveRecordingPath(string id) { return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); @@ -1465,161 +1454,6 @@ namespace Jellyfin.LiveTv return _libraryManager.GetNamedView(name, CollectionType.livetv, name); } - public async Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings) - { - // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider - // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider - info = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(info)); - - var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ResourceNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "Couldn't find provider of type: '{0}'", - info.Type)); - } - - await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false); - - var config = _config.GetLiveTvConfiguration(); - - var list = config.ListingProviders.ToList(); - int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); - - if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) - { - info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - list.Add(info); - config.ListingProviders = list.ToArray(); - } - else - { - config.ListingProviders[index] = info; - } - - _config.SaveConfiguration("livetv", config); - - _taskManager.CancelIfRunningAndQueue(); - - return info; - } - - public void DeleteListingsProvider(string id) - { - var config = _config.GetLiveTvConfiguration(); - - config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); - - _config.SaveConfiguration("livetv", config); - _taskManager.CancelIfRunningAndQueue(); - } - - public async Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) - { - var config = _config.GetLiveTvConfiguration(); - - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); - - if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) - { - var list = listingsProviderInfo.ChannelMappings.ToList(); - list.Add(new NameValuePair - { - Name = tunerChannelNumber, - Value = providerChannelNumber - }); - listingsProviderInfo.ChannelMappings = list.ToArray(); - } - - _config.SaveConfiguration("livetv", config); - - var tunerChannels = await GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var providerChannels = await GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var mappings = listingsProviderInfo.ChannelMappings; - - var tunerChannelMappings = - tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(); - - _taskManager.CancelIfRunningAndQueue(); - - return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)); - } - - public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List providerChannels) - { - var result = new TunerChannelMapping - { - Name = tunerChannel.Name, - Id = tunerChannel.Id - }; - - if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) - { - result.Name = tunerChannel.Number + " " + result.Name; - } - - var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, providerChannels); - - if (providerChannel is not null) - { - result.ProviderChannelName = providerChannel.Name; - result.ProviderChannelId = providerChannel.Id; - } - - return result; - } - - public Task> GetLineups(string providerType, string providerId, string country, string location) - { - var config = _config.GetLiveTvConfiguration(); - - if (string.IsNullOrWhiteSpace(providerId)) - { - var provider = _listingProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ResourceNotFoundException(); - } - - return provider.GetLineups(null, country, location); - } - else - { - var info = config.ListingProviders.FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase)); - - var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ResourceNotFoundException(); - } - - return provider.GetLineups(info, country, location); - } - } - - public Task> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken) - { - var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); - return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken); - } - - public Task> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken) - { - var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); - var provider = _listingProviders.First(i => string.Equals(i.Type, info.Type, StringComparison.OrdinalIgnoreCase)); - return provider.GetChannels(info, cancellationToken); - } - /// public Task GetRecordingFoldersAsync(User user) => GetRecordingFoldersAsync(user, false); From 1a24d26dace17587d598af0acf343f8d193a618b Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 12:08:41 -0500 Subject: [PATCH 086/136] Move EpgChannelData to Listings folder --- src/Jellyfin.LiveTv/{EmbyTV => Listings}/EpgChannelData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/Jellyfin.LiveTv/{EmbyTV => Listings}/EpgChannelData.cs (98%) diff --git a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs b/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs similarity index 98% rename from src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs rename to src/Jellyfin.LiveTv/Listings/EpgChannelData.cs index 43d308c434..81437f7913 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs +++ b/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using MediaBrowser.Controller.LiveTv; -namespace Jellyfin.LiveTv.EmbyTV +namespace Jellyfin.LiveTv.Listings { internal class EpgChannelData { From 1c11c460b94cc2a71e1fd629e6fb7ba5ac4e2f34 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 15:27:42 -0500 Subject: [PATCH 087/136] 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 113979257d..9b239f7e43 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( From 3bdaf640ec52e38e64da25fce07631034538c74a Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 15:28:36 -0500 Subject: [PATCH 088/136] 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 9b239f7e43..87f47611e0 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); 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: [PATCH 089/136] Add PlaybackOrder to Session state --- .../Session/SessionManager.cs | 1 + .../Session/GeneralCommandType.cs | 3 ++- MediaBrowser.Model/Session/PlaybackOrder.cs | 18 ++++++++++++++++++ .../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 bbb3938dcf..40b3b0339e 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 166a6b4416..09339928c8 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 0000000000..8ef7faf14d --- /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 a6e7efcb0c..04a9d68674 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 80e6d4e0b0..35cd68fd15 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. /// From f34c56282d106029d4739d66767c6397f0db081b Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 10 Feb 2024 16:52:21 +0100 Subject: [PATCH 090/136] 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 ab3eb3298b..146b306435 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; } From 52c79c050b638dc6556351346fa26d2eb81775b3 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 10 Feb 2024 16:53:38 +0100 Subject: [PATCH 091/136] Order files before creating concat config --- .../Encoder/MediaEncoder.cs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index f86d14fc86..cc6971c1b1 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(); + Path = path, + Protocol = MediaProtocol.File, + VideoType = videoType + } + }, + CancellationToken.None).GetAwaiter().GetResult(); - var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds; + var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds; - // Add file path stanza to concat configuration - sw.WriteLine("file '{0}'", path); + // Add file path stanza to concat configuration + sw.WriteLine("file '{0}'", path); - // Add duration stanza to concat configuration - sw.WriteLine("duration {0}", duration); - } + // Add duration stanza to concat configuration + sw.WriteLine("duration {0}", duration); } } From 7baa261b22b03fbf8b77c2ae04387af97acfdfc2 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 10 Feb 2024 16:56:21 +0100 Subject: [PATCH 092/136] 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 e6c3198698..83f04d5bef 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); From 9230472056ea92a54a3f47bcb10142310ae0200e Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 10 Feb 2024 16:57:10 +0100 Subject: [PATCH 093/136] 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 83f04d5bef..b3029d6fa8 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 7a3842a9f7..bfe71fd87b 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 1c95192f18..bb867aba30 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) 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: [PATCH 094/136] 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 4a236e5b64..97173d1968 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,7 @@ - + From aaa9345a533d6e8c3fa10b12672de4766ef12070 Mon Sep 17 00:00:00 2001 From: felix920506 Date: Sat, 10 Feb 2024 23:39:30 -0500 Subject: [PATCH 095/136] 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 3be6331c8f..a8ee693ec4 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 5a1871070d..7b510a3379 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 371c3811ab..ccdf017580 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")] From 3cf0070287ae561ead456aea059e8d49e24bb63f Mon Sep 17 00:00:00 2001 From: felix920506 Date: Sun, 11 Feb 2024 00:51:09 -0500 Subject: [PATCH 096/136] 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 8f1cc3f645..4b1b1bbc61 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, 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: [PATCH 097/136] 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 +++++----- .../MediaSourceInfo-mkv-av1-aac-srt-2600k.json | 2 +- .../MediaSourceInfo-mkv-vp9-aac-srt-2600k.json | 2 +- .../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 2f84fa544e..d9dceee55a 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 da185aacfb..e528281bd0 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 0a85a13533..8ef10ae877 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 2b932ff52a..80a9f4103e 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", From 64ce3c8411a2ae0bba9c00046a398e75479f3470 Mon Sep 17 00:00:00 2001 From: Nyanmisaka Date: Mon, 12 Feb 2024 22:34:35 +0800 Subject: [PATCH 098/136] 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 21c6e6f01d..e18212908e 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 0000000000000000000000000000000000000000..0872b956a9fbbfa14c10ea74368f050ed40d1241 GIT binary patch literal 40883 zcmdSB2|QHo`#*l{va6&;(Qc`+k>lp67L(nQ6{_uKT{W_w~N+c`%F)W5RfNFt`d~XBaU| z2g9(XOKI;fEx@o`_)JKM_Wmw>Pl*e|wr-t&zaGN|SHTT%p!-*zz_9E4F-#cV!&l)G z+UIb=Fc@d;7TSrwhd(9O9$5K#N4R>jmY#Q9S?+jFqQcc!sfwthRpWD5ulACH-uvKP zTJ>R@l^P@0NHoPPTlpcLo3++o9&1Tq!rq!hvlp9(o_c5L_o&p?wW-X(rmw=qY`n@t zZ>IX9+H9@A;%wdJ-81!}TgMwB*7r3ZqD(tnFcl2*EtIReGG*R_(2^y2keqbHg=J ztl^rl2CM@G)}b8c!T-iM0fR9--x|kLVIE;6)wy6bmVd1-Beaj;r9!#~`gfx-OIT+Y2QOr8Z9N{Yp=!;v3Usf-8tW8Sc`4oV_;iOf4cA24+=mAcs8=BeQ%8u z8jH*#T8htJK7c+eIp_Q9SO4Z~SVOEp_bDCNwHU17=67d<=wO_`{62}{_aC!D(742l zVP15!6a70ny8Mnd^Ctze~dHu=YRN9?s5vNa5Kg# zTu(89pKw$Wbr)7&R9{l#r?Ry6(%z+DCrjRkNGzw*VHn5 zJ8HS3CHSc+73M$%KcQA%RHcHiQNc&3b(f{7??bjz>%+vT5PPVNQ7frUu}jDhLwi0X zEqKt9!YI=m$Bn%;N??arnp0$UjSBikHbX^r2Xml8ETTdzp;o#YQz5R-LOdbY_#7hF z`tP5Gn1KA5S|2J-#p6okYHCyTa%%I9MO2WT3NbRjC53?p{E`d&su27NY%-JzYX$Mv z`#Z$!H!z22n1d63di9Qdit@jMWR z*~;M=i}LmE(SyAufW1+{@2JIRg8LvRXqDQyb3iPkE3t9=OTD2F>#s;)?}KD9&@+2O z_=dv}U%NpMFo(s|5Aj@SEy+xb;7=Hwr@+T45F@EYr^6bG&s^aLecnZSU zLlkQmK-Uaa7uD#%rm*%)kR;(i@e*P<4?Ls1@wy5&vGGs` zd!72Tz3Px5zny@2V2~R)K-VwchwK1*-82c$&;Xml8Y9=@>xbuw6X_g~Yo0-jrGl;} z;Tc+xH!+B_3nAyc`C+e+GpLY*sgOS_z@C=Y_#MD%1NNyv?ihny4EDN})DR}h1JA+7 zMt=%Vq~q%Z&p8J1ZwimWCecs9?>O)|SbG}5I!!}v1)ntqRjfr0!B;2HO7 z1NMT?$;^OX8^c`iHT(Gh^b9=4r*nfoUV*M*4mT#ip0tsk!Kb-kow6ZDgU{JQ?zDIf zx?Y6V9`tYsa@Q#2Kk#e98^AP+>Vjl`9U6aKc;X&S7y4;hkVhs^PKG>C0QSW9&cYwz zteubxIzZQ7${lP7U{7GP20T@sy3aumRIsHf$c_4Fz3L#x{?&oTJqCMSgmM+ilQ0Kvu-9~W4lTaA8AMfkX)%Fc{N1sC{3EVw{q)tK>*{%X zokJK6{F?opr7tV!IsojIjN&Wcj7Erm8$sVcZvP*z_&On<(@)cZ_%RCk8qHxE#=rZ{ zBJd`}*U3^FSHKST6X4f|XigP?Xa1Q3pM%~#W*l45>=nfgh_6==-h~|DNiDT?jsbgR z1;6-bhW`0o6q`T~OCdHrhZqj_3bE;Q7!_~>(zp+kfRK0DA=;BWxrJ zK377EO_2y2RYM-y2Kf#vvGo5}hW>fa8~s$ke~Iv%o1$>N1wNMsxl|ls$fEfi@$<|7 z;){Y4^VrNNkp=9PwFvNLfzhphXfz5Gze<^?F}=Rx(tdI1eq06AR}u#X_rha7-S=%L2@%Sj>306B}k_{`;X;6p~>r!gQyVX2*C2l&x2 z#G7fTHz+8kLH+_v2X%|LI>hEfR9gH8To)umg&YHUY#YLMkdHS^Lp~mc+Oh+%(HnU7 zjSmT&CQ#>Y1dPW37zvjJkDn&dToC7oAV&w-{~Y+(4?hMx3AqjGK2*1%+7EIA4F`B> z&f`iIDy#t&)?gl6N>BlF0QM81HX?j^9k5x zVmY}Od>qz*7DueSsjvp~c=J3hzdE0$<#2a>SOZ;H1ISN4hv%`a0<3{7U{A>9A!rRY zQE8ZD4d5OD8a|9$NW-8>EEHG+E06=8eFN1uu2AdJ;uxxr&IZooT_VIXz|?jQQ-Irh zU=8ZQ4&H(tJO?|-1Utw8JIDY#$b>a`4tV7)*ui_SgC4-YlVAscvDd>I0Jg(1FdyJY zZiG=04i0KbWTV9hqeK%t2GMXN!m5CW17^Vv-a+nkhn#Q#>_7zUUKgVKgh7r1>s-3PN3Oh1IVx( z_!4;bZLIKA43gn84Oie;8|9mfcW16J7Mowi9}hNw0Y+jdvp>HaaLf+C7>a;n)Bty@ zfgY4V50ceBssh08FoIv=_aOXT<*!Hwxbg=0Bf`h92DmN8v?eptK|O(g4{#aeqT33v z7T;0MrmaCl3&AOu7JaG=o15fh|sgEt0_&$zY39V2i_mIa}e`-ZlQp zOkk5Z)~pZNz69i`{>>i+CqNH}K@JRTa{l>H3+UhF1Q`H>A{>p@;5r#%!U-Jv*tmlY z5E%hG+{3Y`{SSXM{|kRS@)DlC0O2|C12`oQL#;OPi$4~F9u|U6;Lk>X3Qq#xL#q`| zgC1t_oR9p`H1a;op9SPV7!uVCwt%;hKjJkg;Nf{3W{B!gDy#vDi(UxtfiD8S-^&R8 zm1 zNh)m(pmsIA9u0Ak0b&AHY!OHYas<=X-~z2Ca71fhhSz718*V@?&Il*=&fW=wdOQ&1 zn9mJp4Ymb@L!L%<`Bw*y?ex>u0{;3yTLau5dtv^PAP1U1I>mqoXa`&TnHvxn0J)(D zIMSVvqcGqC;5;k}xV{5wtRK1IBj{lh#HPP`H2xXR+ZiSzJ$Qm1U=4t~L$+uV6&dQ@M1nUfF0Nr0Y@wVa$u;wukt)533l)W z{1I|Ac)%+V^bR@de|$dr!u%R6gEc4tfBa#KQ}Aq)w76dye|HU6?Ko9013s!Ye{deF0pWe{c0M)c<_n{7% zw?*6^Qy?C(qqz9vKy?n-A`8@Q5j0!;>5tDK7C|k2?8jIC;p;CouQaT|mp6u}uEzZl z^bmQz=xi`Os(Zl(u(l%5#d#+akYk*N)o_382R-aVTtubE$$$8{zkU&|!5h77I#>e_ z+!lZMqv`d2Sc7Aw76EiMUOI@UI1F-h|G^)F0HZK~J^VW4Km8il!-;f0u*KIjTg3el zVYRP-ncV@aT>x(K6ZpdqtcG$!@ehCeryux-8-Uk9F&V`qIamYG1LQ}X%fdN73dCg! zU^T>DQXw}0UUljRJ*XgmEO$8nhtdAExA1JNP=kczu!c35`k5P0-Ho_YR2N$!ewIiE zJ-C-$u%-t&{4 zKn=AXdMUh6!!dSZ!&q}NG$a3s1Nd016AKM3_1jsu_%iSyV9o8$EX^dnLK1@$CRt%f)(oI?VPOv9)!2Sw;*09MDjq}@1| zgZF+yx6=3=*r$N=DNsk_wLEYtc;63ti%`!`f&8r>5*H)^w{i&h9d^X;wBG!U--J2> zgA?)j;ZReJ;CTzro&G3yq5KJ)6XbAI+d%INYW;cc3u-K+1H^wJ9U%S*=>YXsfrEm& zAJ+liV?{c^Yc5$DABJjA#77}c4E0=5pA2ykhz~)$6yhUd`DnaMJQwgX?9`SdmNAe& zrS&Gg0OH8dfp{v&b#RSwgm_PZoI~SjaIWYQYL= zNC&9S2L6#&_j?#aY?GQ`y(9U$z1>i{@Iq=R!8Y1}E! zU4jnm9H5_LI|#9;6?9Mnwfb|YPcnc5O_&;i~%1|7ul0>_E-dmukcAC zuV^m_!cM@i;5xv2{J0JPM_PFe0+xLZ@zfV|paXs&0XkR-dmqnalsKxPSM4;6kwahz$#&Pl5R1 zzlx{RFb8$S(f&B*xmrG$LnW>Q+>W3(X9{%)&6i0q2X=_7KW_Q2U4d5yOtFa`ba02( zXZTY*ZG)V!8qS|GAg=e#P71>C*JyPd)RJg_2<$aqwkx$jjD{m#rZ$pMkur2k}T5;?Y*v=dl?0EBe3c%|jeQ z`(uP)4sD=|`Cbpo%P5{MCqrH~0Dr(>9={WXR>R%lgL#yJ9Z?~!{^0nKFH;~VoI#v7 z*wfGZ08;?AbLE7b<_z()4C3iGz($i0Pv`KwjQH2zu+bcFK9i7_`vDtOf$aW(?-nCI z{KwG{3APg|j!q4j!}KrMXg;0_G(%1hf_eP7<>wXEfNvod!90jyM>JmOr!PZ1H4DFA zWFE$X=J4Y{91Wb35Kjl8r#GL|pvRBrG{n{XJ#2Jn3g&R|R~;Z;y()k}2fTVD%;B%F zQFvQC4;|o(A0qtj3NoX$#Eb=RJsfFSYSl2Xp8Gd^*p+ zBWz@a_N36Tk?TA*@|ed)UO%zXAYkmBzw&>O&oDRzAtyA^@F~JZ2%qBEXr%G_Y6a+( z{Cxf&U(osmX=o3^ZCnR9CxhbY4_}UO#W}+HIY2JJ-dPjT{)&gc;ZuZ-kPcwq4vtTM z!$vfG`V$+y1v}yajE9SX_Sn<~OTk`KX;YZP?C%qh$^jqoX6 z=Kwyvy15i&Ujp;^d!pn2R;UMimT0`*NwA}7nht)Ar`KPB?A%3Wk@)vZti7;z;Kwiz z7o6wA`@(+@pW=1S)f#UNdYA`Iet#vb)=!xQu+d$-&I$S*KBWN0wyD3e1A8B~`G1rj zeF4|OiS)%VkGJ5nFpt|X zk3ZI$6!2vy)H8&6;NSm8|IGJFmVhpbz?X3y{D`Me=b-(F$ML$;^oI_-&^-7+2PB#f z{wkiD*FsKM4JS=@@1sa|*z>2Z2yti_asd@M7ua`!YE8V}e-E665S;(0*gwBVlONyf zehTbp=Fjmo3ijwm@*z7yM~SsJoOmzv5a?j=FXAc6%SOOKV-S<@dyxMEuHXP%kp*$+ zkF_QR*8Kv^2ZNY|fB&ETGp~b1paT-FgWtzfGRS_e$Rq}@pV8RJj)1RVgn0x)Jf$Ez z`la4aguKiKc>x!r3i^p4JL>7_!aOF>{#W3=@Vz(lwPqFgvH;Y~|E$f7-r!H6|L|g72b`dThv3W9zlf(HfGHS$`tmu@feoJ1)`JeRK?fw@ zjdnn8gMAhb_~=k4WA~NoQD4&q<}nBR9B8}|t^?FtJ)95m=y%xe4`ThpZ|CKQn3}#H z<}mmpp5psu@g8(E`VGi_q=TZH{lVRhgB@w3zT~gi=pPpNFTO~dLk1IEqYL$s4*rNwufL}GGA*9|#SlL~ z0$hicIa+d5IG;)X8~8LGe3=b!_Ro9%6EEiVunBa~_Pcm$d;{%^LHlJri_EXm|G*W0 z^-QP_py#O%K45hg<}vjb++8pDv<&j=YOjC3H~Ux5{P7tszaHAhk?sJ#Jo}q3|G=kF z-vCo6<9755#{O|XvVG*gP^aw#eA?itBYvS<~=YVnU4)yR+FT}$C zuX-V{pa1;7yr2KyogmGZ)6gD}oZsT<5AJTBho|wU_&$W+^+Igi-a$MPg7e=M<6pj? z_B$eEM|Zb_4x0arjehHepx+&%{Ur0fkZ!Oe1>i0a#`u??|DU+A@Fe`MS0C|SsYfB6 z_WhyOMBLqcF9he|Q7>c^eA%p;sE_0A|AY+x?ih%>Bk5(p?}G1~179Aa@u$D=@OUo- z?R~yH4ZchS9WcW@{@us^NACyO!H!ay!4I542QUZ7%QSpCuY<5@;NLGp4}l%+)BE3e z;Con3-hnyXv;^L$m(~lxxw|3AX&w+y+5d{e{@*kgydPz7n;zts1s%kK4w3<%9w;`8 zU_#jL|H?t@YbMgemEnzH0#;<4_1Cyb4*_;1~R zTw$xp&)>eYc+t_mL%Ys0kmyJR_$@pGi2+?Hbe#-dMEuHvu2gg<^_Q!c^C~nCs+X)B zekIAdGobe*!72RO$vwq_-VbvR)2=L?-01xj1Bi~UbYA#%7Q+bWJq5#vzh2R6{6rhE z&}+0A>(?vVl!e}-jaleA1uKBA6e8_Pg#*I^)m4=_*_NY?DV!=Y_E0RNA@$V z>tFc!n$O*=%Xj%z+$y@XGaKD9%h@&^3ZYlxNV-<|CEk@PmG)9?Qo3r#Xu17qM>P+* ztUWznQxogzPip5qNFVf%wMb99^UwY{%X&>~j$ZOfVy=s+T0l^+X4p3Q0MRm@_Ql2{ zHM@pxYv&mr8MvuOcW>|5{k3j7x?yd66AX^pVOO-51rDbdWQ(@?eD5IbDE2VPG}F$r zIl`d!g#H*)?+N9~6`{kP+Itp;If>nSuqG*~h9Hzw%Ep(#;F*+?bGOuFW9zHN@97DyUw6UX*@~aMHnbQ-KQ)EZ!?2mY_NHBFH_1KjDU*rtWKr zV++!BFfoxfyU(w3GPY~`t@R#XT*263GiQ>oEAfEkkv{w||GB_ajf&xr-mwdL78aZ9 z*^JiTJP{JNkz_3#m6jE{S9x7$n9NHQC_j{PgpXm9W>+zZ=}^E(Z<=976r&ge zdQ7s7;5M6!1^EKon5G6%R3hOO|QD+C`Ve#rB!=wr=)V5)Ksa)&gJSY;5h&7 z?hKQ~H$vNPm-gKxMG}XR^Qac=w1ay1D&ab1y?q9JnOlkJ(_$c zSK~hBzZE^?rPm6rCK)F`*6_f567M}0l z%hn(AT758Q)ixWMPBGE`c-ib%v)$Zrka5cvgwkQ{14+4l9W`2+fF&Od`?)o*czMI9`9gr%t`xIrCd(j8RkCwx|x?<3jB(u z-8I6g-U%LW7dVC9k5!hGRE4|tqPy;US!!&!)37`Hjr^+wwWPdDoO5l_c?UN2pGv;; z*!t1_s~j__c`B6(QuL>9{HE* zsTW_NnBO(#7-s5>C{s<3B&jFq(Q(kdcuju5sr6a3^R=*XK)fk2;y5ulD=~&=u}4(- z$VO>c)6-*n>cWISd1dp3y#9R4t#4ZLUD~Q71=Sp<4oSMO7!u39t?^Ad&$oV67U&ai zj+*J6%Kjj+DDdO=W1RvTlq)5Z4ar{=4v%+ooO9>nOMA}_%4~tn^~?8(6LK-bsa0JoSsSk$D_x~Mn!SMlcFa?oxnYtu zJ%GNBPid^o*gT@{q7V7rXQAwCX{N6N_ur|?KU1Z;?t?(rwaclkFVEJy-xonD_Z?H4 z60&_5v_DXC&4g?Fg;`17i5SWq&t^3l* zdsjgoX2s~x^En}mEmcH-)8X@K2c_gHyKb%H>rQxFsfu@}&RP?6R~rej6^`^MvD&Q& zem^XE>(VKP&Y+lTZOIPHbUsp&ke?cToyN)s{^L20{m);{9qrxh(rd*kX`M@)xN+Uf zJizM`=VbV!>D;a?LrlYTXi3yAhFl5znb{oO3bJ;*1(W)=HtFZ)+?|{JMoM-he##WE zkWtxfdVq9cV)`DDzFyO=(C^N;_(5f6?v$&tdwC~xq()zz9o5Zn<&VA+9t9J`UkD%U9bdPs%e`vCxc9)- z9s9dUJ0In4@Byjm+D5)6aCJ)gt@S$aab01x2OIf#hvZS8eJ|&(Eji>!K3?6yO7^Sd zKG0F~nDUv|kN2Ryau|D6%=wO&>8ql&!_W{a8_9I3Yl5}*O^hxc-G1h%CDqd(K6FEUyy>2WmYCPhy7+*}bIkhphMy|Mxr(Ia z^gg5LCuxzNWk$)X`_f>4fRc-+}#%lSQn_I(JgLoBB z&xKDks7v*fYYw(N{WhYfjSpd#wW~mdP2*igO_t$~X^MB2;RjEZT!KpT0&-+GxutiF zmoSIHu}cO0%3AHrQgiI7t-3udkG(CJs+nWmoU6XDMqxMhU+j{iocgBQl=;=2>MoO< zWmv6j{6%1CvH!+Iz7tgUwz1b*1ct-Yvm#FjMoFM(hQszR-HJrw6x~CJdlimZ=l5}} zkMN)C{WKsabwPh)e9XH##uQ;$DG7gy^~s9aHEa1)_kH$CFSapDh0wS)&2DKyD&4Dw zyg+wqN{h#}XyT!Ghh%rET!c?zz@p0y%(>1*BJ4W_rZn^}pr;+M=N@YI>m7VmqsRQj z-fTo}qpsujfTWQ&PaglTiVIZC0{3?+F1GNUJ9;@Whw}BBvayqIohrdf!K0XjL0V(J z^u=-V-tDc6j9U(qN(()&vK~Il^_lD@K)iCgr+cAe?W5Iqzicxsc^RF@U9mPO>yx-! z!b7$s%`WH8>hxlJv)dpv*4Es%gF|Y;6i+9w|222w@QGRG?{jmiyElIBaWfL-vKRR{ z+m@Fl=;C`dLi?fW&dP`;DZgyhJy!Xu3^EoO8z35C2Rh}oPYZF3>QUD?TOBx2Idh^z zlJQP{R^q{_Xz`h_p&`Y^X4Q%A(%*un)Za9am{Q|%jjp5@JDLIhc^nOMIOk<2TA(ML zPvTiB+@xBD`OXCyyYBN`aKW?KJ&|O7MNy8!?2D$e##gL!ho+jLk&LXxH-4~G+_zbc zVhiL-vISmU=z5}Z+Lv^o+j!qnS!Q-coV*_| zDXq|Gk}bgbHj>~fqg-;S$^rKWiCxVhqEn&MLEm;;8rjDfEimb@o_yFT$H=27)UWF+ zEEFuTZdg`bb#9GPVKeha#jbeL+Z~n9@^TH66v5|~)U*yJ-1pw$T~C+yNnB(F^}2L_ z>myxtTi*>HN2B8#FMszB%4K^b<*I!1wGywQLX)_KvYqgn%`zc*wh2Do!TZ}8;-o$W zllwO1FHx^}v!oiL&RXxPv`4QwGwa2sC|QXEQ|#dSU5~O`#9bmN*LtEeqYV_D)w^^_ z`$#v~RwO;zRp6%Gq;I_cdv3FFvhjZ7qS3lttbTKc*Bdo}K&6lNb!C&PykCl~7>o3^ z2sp@o#@eaM+$%F}d;3EFbr0VzwkTYldSQ`b*tzK=o9^*mznr^*@1FhirbB0UuLyU% zX7y$hvuEsvuZN5O04B+Ci5S@(Q)L!%L0d^_l%Po9(UI!!er$V*P8~FMf2YEjj9Eg9T9c#W>Aeabq^C zS2p)B3*F_Np%AJb7O;r8H$Bddmu;=YvZjG3cCtqQ6uUfehvBJ}e3f!v*(Wn~D~7o} z(xWhsThH6}+~8H9o+Pu+?QkB*u(V!K`F-v+zvCUtv&T8~Qw!E_++{Rg)|(^_$D-#=ODn zf|*sB9ghgk6nHK;?D*-M>hmtC=b8N}%*R;Wk9U34VI`2H4VypF?Y|?^IuOu5vd{24 zkCJrUkj*~gLq7t)qlEoNEq{^2Uz40Nb;d4egC~t$;bICYO?0g*Xz`FWCS-o9Qd(dm zY8RH0t(x$WM3>*MXI&#J03$uMhiV6W2Xe==-9XQQWa<8NJD4hGwUmRqU!mDy3OVr<^)t!_A#u zOY(RShRVVdl%i9Hi6xhhSzh43a*V=JIP=ou!b;-V-H%X;9XPNrg?FY&xvS|@rE5~| z>3a)6k&7gDefZ)gk>$Y^%im2taWPpUawt)jKj_iub^2Ww^>>*}BwS{`#?nJ{sU6}` z)qO_ra48<)|1e-aBU39EbMD~68@4GMRSaKWS~J(i)p?hz>8ubr?-7LO*qhw+#VMEi zrm{nZ9@U=wYF|#6PFlB%C_T)lSeYp2R<-LxzHdz95m&66k#BG^f6U#Xursu;eAIVt z)3YJY^{y#vG&FCBx{o}|UKL%mx#Mxo!8yBzbYD#6VAUc z2GS)~F)+AH)uEs&VdBc3rVv8mOwZ`pO73Uf<2yIUdAwAkWL5VjE;XPokCwJ_Y!uTT z?!MT!p<-V*y>FC{23yr+Yt`%Ng_Zml!$M~lS}+y_n^d_PMTt6Ze-CN=N{s>OeDo}v z@7%QuHrpR=c2>K-LVf9ky@v1C1LeM5bVfyE%_oWJ*S66+VLfy<*Zt<~W?6mb+`~d{ z88|-my0uy%c;~0Q9QCfxH>*68T<^+~Rcv}A9C-lO1;=Svr^vnEXY zKRB%)#GuM=_>x>E8W*rNZ+OZ+D{80zICsyXrB!nwdBNt_KSu1~IV~{$mPHv2d+-T8 zYc*t*ZKO1k%zaK-)h0Fe0?*tQkG-k|{r9}&o5V$lf?cIwvtM43Q90r`P(DCP8;K#~Ux{W3LiqKG!Yc3oKpLeJOc={mA7`o4SXTl2J*%JFCuD`YcNS zQekvUkIq>*WIB=Sp3ag9b~PoI^^2t_dyNTe!v~rC?C(^3b$wyc9#QkQ@$k{k7E!zK z>L8&9h%@zv8+j4AoDfO zu6o2gYQ;&sg@muiV&J_u&1Mhud7Ivx48n=%{V#3TDf%&K_mb#04> z(WSgclWrzBbm;#IPr7evrBv9%_6c}gQygV;jr}S?a?69 z&Xrg{iz1iIIgu^od+%m6c_w_Kye_9!esz6dacAsugy3D<6{e-zL}gS6_ZN`6KRk@m z+^y3UT@^>Jj@8aK%Tm9Oor2gn%9WDU5&f$0fowmEMBBHPvlv*BC(#w=WV z$V(>}beG<%>I@)BYJPFpd_Q*A{iKWstpmravw5diFbi%DF!25)-kq2oK-wMr=2DWg z2dhZXRo?v@R}E=N8ZrYBB$V>{(n6&(8#?j`#ssmi#g4OcOP-x6jk9o^;#`_W(d*Ll zGvm>TljmB$IOouv0|La0E?Mc}`?AtR>*OvB?#p-L=ToI+F2<9^+zcXJ3`P`B_eT#U!`?oIqcqG-X{!h;6E{lTO7|?-a`|iBsE4 z6-k1J3O(tH6P?bkmxz%4?7maA%RV5UTCJx&di$W>Z9e(9kzFg7##hx<`R;bARz5uV zW_;DC-!^0LN>N!h{hi%+yrrw{v#N&;mfW?K%v#q!b!cjp?d)@7!p#RBd0xv+Him3x z_Vv(^)0R|Azoo51_a-FolXqJZx5|c9eKt=`_8hoJbk6C3K(5*2utysfH1@$Wixm0} zM(doNM{dVlont2}R$UodUbeaF(K)Gth{8(Z8FKpcQNH_+*t~@6C!LeB#iuE0#d`hw zQVt`nG$!7hVjfs z7|XQYJ?)~73KNgbcs%nx8;|O9xdelZkIGyYgk9eVT;(ylQ!S6zyPw^7#q)Bi_X?hq zu`L5V$F05Nosx^TmvWYE=s2=PbF-WutC!lPcW&ulRQMhr6vd2)DQk1dt0s1od#ueN ze-4=r%AJX2<3AE#H{O!Itk(L)Y{nwKt{ZF}BVV+x^4%lTpReTJ`r&bbCsS0~#ZM91 z8-xWWM~-u6>PQ-XIFb=Z9}U1@r$$kSvtg5X^7VlUvC2v6lHs{@m!o_h-L-+48Kvt< znz`=O*8F59mWy8sJU#tZNCh2Mms0OC(H_0k_PA68OW|DY=2K-H)iKa>Vb<*I?RFkx zpJ@uWu>P(K>q{p3#5@Kb1%~?U*P#34(lo+PJ#qCyjHtDt8{K;>nEno>r97a+KvM04 zN=5`O4MWW|rSN32;h>&P=mnLPM98F*j)||HlpLojvNo#1M9Jwc+l3EAv(M7IAG+I{ zW|JfM%+tGk27LVNNwF20+v1*K{pkfgT+chZjJ30GoqSv>>;=(qt=A&&`)yU<7syZ8 zm$~R(kB!v{EcE;`dTzU?nyzv0P;Dc{qp?xqMbnr##0tsJQ`vbdB4xa8%6zOIPM6OJ zrpFu^<=8?#Rq;=Z`hV}wG9*|}PRFz9B9Ttlb1XBJFzjOX<}PY`y2WM?!W9Mro+muTM?eGw9DXyrFg-) zh{RptLy4h%Q75Rk3_SHe?Y3RoE6Te>fBj-?c(r>2!L!;H3UQNMJA>ZDblbk*W}m*p z)cn2K;?3iVDU8Yd_FeMty2s2foQg1#+vTw%L~{wh;E?ezD0i#f<56Wa_Yt{r?mO&L7SaI9ld zM!fu*8nRaR)%2Kc)$8VLP7NP*f6{oCw4RjJdOO^rU0K|~#?w^eyGWjO+xwTnuNw*0 zsk&X;`6^dzT1M=te5R(gj3rpTtKxEIn@6?&PP_8vNgF1+08tsmi{i^d!?$f~pfopM zE=m^q@~C`d_Jq0Q?t|;4P1os6aEl7egxfpK?yHwwCal-YODZCn3Ut}LAK|axe10~} zWzN2Iou#=qo8d~?0s@gUUV!TWAEHB80 z5cW;I_N1#5v6Q0BQhdw9mWEENzAKFvcw8&$)igt7&Z@fTwAxMO`PQy=B9~I*YF3aU zA(G79wn!+CJD(FLV?tTiNzi&OC&y!QjZJxNYoKj=OloV6b^Xw?I_|~Oyw#Igf{q@N z5rOGGSnvxanU!qH0uD86Z)jHpd#6O|&|xpJ;;z97hULuzEXM(U^o-7J?iG~!a_FZ1 zq@Zp^@bS9Af&TiCLN6O1_oYj}PVH(?oT9fK7CVK!($z{`{j4B*IXJk zhvXaRi;sTs&9`+HbxX6Y_?o)e**sF5djGQ1v=3WU%B!zm$Sh7|7Xf10wKA8vit|rA z7%LM(LWO49MP0V>JgybNI@{h%=JTKKNt+&aLL$)I~Arfxh(blG{z zz?hBj;@nNVKUJx_KW`m9fhIpgqCRUCc%}(B4x1S!{)F#=YE;o$7lB z&9g72d{PLJ?K~73azBE9^PR|pbmaqs$9>KeR!u$1N+gO)N3@@?S(o%IhrI5>V$YO? z1J)Tqu!cmLPBtTZ8wZ9HOE*0t0C(M-n()F%U)y6k^s>|Oy9JGl=gbSv$hha?wxrS>8~TogbA>{>Tq2M$+Owe@62kgiPcQbbWZUV&f-DsN+8#?Kuus4`Ey z(=%d2qrU(#rvA;b70MC^yG%9OGSfZV?7AM5X>~LE7H?)c_u1&P6M5O3WzmRv^7Bp7 z!a6B#cWsYpyPuJ~FTsXMaWqL!7vA{H8{G*6@biWZ>7&A1&A;_O=+@q*(4`(BBKjeQ z%`lKiBb})6nj)etjm&T;BT4GXsvehYs$H9uFxhG80Qv?N2Q& zs2R%pTyrG-<36#ox7D$cirhAN8seH0ydDLp78l8@e)YkXo=d|6Ur9CKe!u)Gq zxcVR7{!C!E;x0dmig{tdRu#o9F`h+pktZ|n7u*+}ZC#%DIBt)`+LbznNBRu63Z`^y z=Skj^`Lt20T=xChcVb>r-IJQ6373HQPu1+ZDOvZ4T+F8<3g%vY8rrgMLQ7YEM{1Sf zi7r{Ax|Cjj#SO$D-+Y6;EBCsGS@1cuFv;Xyv|C%S!s}s+8fU82>H1LATDJ~#nVBg>8k63iHE$)P1?3hwL=}i1DsJgLV687X zEGu%r!0=Sg%ayiMo>WTj)$7Tt=e|QB>Ay&t^3;0FQP8GVtwhJLBiBAPekf!3Cfo7z zLN!l&x;DHD?byB9d2WsK;#(yQmSqkzBd1AGcRVQ+&Wzsv0sg^Re>PouqMFGxlq;p& z>)XEFI7YM&9`M<>W4*<|uBS0H!K3oB66eZ(+h=S!Bwj@ zGPRvoJ@fn)XR&2G4s>;gttaW8N#^m<2Esu2SN`*pMJ*wO6M-MLx)37LG=Xfqc4T{c zD~r3#fZ^D+?hwMeSJXlKx)F2tRhz_yu#o1VN6G>HO3AA;uv2|R#w>-i@UUl=uh$eC zsx(#Ib-V0nOXQP{=JT69E&o*GcBXNj($l)#-CVw(!h~C!tYh8sE6Z-m@Qsh$De$z8 zhpPGbUY9wg6yezwyB#!0SG6&cDk=X#^^0Qn_3yu#`I0Op2uZISg6xqSVg6`STu}HufWVx z?it@o|DakL$KP5qWj{-Qmb%H<_G-jhBQ|nc>BC|oCBIU13Y$Hqy^4Ks;_{(yNxrxC z`WWZU&R)7l{9c@#a-6GFJp~@+rLw=eEVlD`o9CUkkG|~=A#iXGoo9RAKsYkPYfUcD z{Xi@jy7zT7KqT&NR@C_5XTDVlJzvc$87Dh#^t`_irG9W@LEy?1x2HE?Cx^6sPY;jv z(PO8rZA1qSk`@a(4Vz<(7xHk=i`AVr_a0-@tO*zo+a&GvhGl6 z6PJw5O788$(wDD=O&+7(CXH)~zW8wH<{a0?6o;9Hducr_-!JZZx-4B{W$Y^>;YYl- z?LC$5M)5&Y=UEDK?J)G)Gk4V6-TRTJBPST1QST0F7H zzt$x8aIfHtHsxexzaj0K>EjST>EcqZTGx#0ZI%t+HE18Rl`+mUzputbE-&EB4xN^- zFLyY(9*@l2P@-V=a?ZU{)@cFXV@d*#@$3!DyLSEUQF1$PJ!R(>VcH5@u0-{f?d=yS zO7R6Ixijh$q`qC39LcZsnYblh6u1f|eyS3idN@A0n_{=9_4&qp^$^E3VS&L%y_emw zZ5=V+lV!%|PUm@hc#)6Val;ku1N>4ZNnv(xGA37R#R%0)aEdU=KmM{usA_ihN&A9v z%N)TG7ROrn|1~^mB!WG((5H$8AKa9+OZy4fbJ0qofiq|SE!*!8&G%GKJQYR}H}wz(lO&SgwIC#WWCGM#3t zv#h<&Z_g|iudd26)TeItwb4EQvI>kr`HaK+=AF`^5|jn)4o%{prwTV^(1kB@wyK|A zJe=z|x{)$HO(LePzU$F6(4!G&P@Fh&GJ~S9#sQR)TYXgn+t1?RnT5r=>9?6$zCK;x zDOYSbHYvW?^SB1JgWY|1bH$ev0T*iwLqCb{O6D}ZwPE{qmvh3Z{W_4aIKc~p6N9!YY_4CWeEHS)r#P8F@tJ#OE&wKS=RmW&_nN|!> z=_I$`T&<+#qDEI1t{GlGHCmT@k%f_NNbrtkQ`)rEw3^UuXh8C3 zC|>0;!LTCdhAtuY`RH~(8(~@g(FYRy4=XftIvz`W$s5jL_JCR7bq`;tu4Blln-aZz zs3_4O8K(zt<{+=H+B5ugQ@^<$(Xfeo>LuGQs4Sm6rp;^v$-%GYY@>%|!_`lr3(eA) zF0e;w`7$vmMxOLC3Ekb3Z#?iNMkw@R>(@|+2w@F#PsYC3SB`5oKm6$7B!2juQ^$?l z3YnOfhZpx9;)3a5LRrkBs827hNKt|sUYw6N_U z_Vrq`Az?`l!gd^`)&pN!xT{*uGug5X=lHKAs>I(in%ea$@U;4+LO-5oR?@9Z#CTQ`7 zLl@V`r4+nusmK$I&;Demox5O`mm_*m)HO$Kq{GiRkLP1~RHIgRbfB(myCYMRFsB}q zt$D;KRoZVswa{2WOux75Q(LJ;0*97T(zm``)0ely_R_M6QP+EW5Hl;Dt;aRCYignD zIyV2gkB)~aL4j*F2nl$6mgauCM^Pt2Su17VKKBDPI&q7S5&0FP2c*h0vQ^)I2}qNt z+j-DOhmN)XW+@pq^z7ugdA{mt*VK;C>4KWxx~@80$}($lUzt`EP&WzXF$LSy4*?d-C z%H+Ge@de+Tj;}3jQ@j))L(NeF=sty;KYb!%J6l8gpvCshG`vf?lW?c~J*7}?@dSgM z&hm*uVc2K1vDR3RT5$J3*eCJne1Vj$QHBQVvtmUxUsSnd%@qfVsWCY1{Pywy#axeS z+wNn0wRL?2-};)#~Giak7PxnnNR-mzbM?4=OV3OKYwGb#}# zdJ_WDf@7D3EHmDjJHDO?d>XL7>`b)?s<;Z(xs;Dp4P|B9OsWWrFYU@o9B++&rX(Xy zmvi`a+T3}fAGdISM^9E_OXI9*a2ZoiuUWF}t7!^AkqqnEN!I z1eG7Rxg1XZ6bcRF0FybZ{U)1k1QmKZ-iTPkmeHTWTBq#QA1oD>^VHkZbE2}0Z<<@% zrIMvC+w;cDIks8)gz!}T?&wW7Ia2$T*pvqy#uxc~>nADPM&%fXvNT(ll{}Zl^Jz+Q zmLNd3*&~Gkot)%3_NkeO8!wjRyri;`&&GWhaF|V-d$qOS{OPB-p^jl|LKKnn7}eDM zs9(6q7Y!k0;+Iz}jTXCXWFi8*hCWA}sU;jL@hkLH3?W#SyO$o7){0mOrnfg2HW+eQ zrP^+lV1B(x^t)>@_dvXnuWwgs$^hYu+iBA|!sYF`&F(~ONaTS3xMX9-^I*dFZcpDi zw$3L>)l7CqFQ>_);)7Xx{Z4r2-4-`hlv~bD?skl1-WO=#^EjC;v3=yj#`N9tlFB|x zbh3?YYt!8q2dd4C9~r*&$YgFs^MLuPQ8U-45B<8#I#*~~r>`5N%K34xBS!4CD&;fn zb!+HYlA><;oY!D)^9AoDe%YK&iR~|7Cqi+a?6g=VbeXHs3-c+@t z=Te_^(5O+wk(Rbe9?s=VwwaF>lw8S>wp`>myZ(^3sd`ZL#jgR@?71qX5F)Snw=l zW#fpss%?wP(_@_tB7FUmEE9^&jY}q$z+M6YB2Z6Na|trcU7C>$?=U4SoZv#)XEn)` z)Ouw&Z$Tcb;P;pl*3Vs*oL#ZLY;#XUI#;J=iy?Xc`bp6j62ed^7P{@+8_nV1`ug6s zak2i5%~AByt)meWiQ~Iqqmst*l{Y0TD<&JSdOi6VEpB;H2HZX(>d@Mq-@b$j=I}j; zG&@VB?AM;K-jzos_pfZHx7KmK8MaQ zEfcH{j++D=7Iqij#GEUALA6ng&wby7<3jtclrCa&CLV+k15F) z&d5vOS=9LWSZx3DF)=!Jjp$e6V~5ri80CCjmik0~1M$?C!hyV_hsN^wrjLBzZhc8= zwbh1xe!JP@?nfef4=BFKc|90d-_q{nBdU!#t!m$~yo=}mtL!Y;+UlBcjT8&+?gfGs zDbixWDTN}1phfZm1&Ws9Zo%Ct?k>e$LUAilybvf_+&$ssJLeaiACN2iT5IjSW}dm9 z8E9f`7=p#LF)2lwybFEUj7Rs(2b$O1d8)6TC!n~f+MRr5Lg)GvwCH1^>)yM!aNhDy z2JgOB=-fCS4N{&pO>ZgG40kxJ|jXRkpHBNT#?;fT!G9 z+A!^(XR*arWG)Fi%Tmd)Uwnly77OgAFP!|dk7oI~+>I-lBso8j0F~1A2_)Pw-+e`1 z-CINmKZil9V+kh-t*CT8XP6x%c6oQ+mYz}X85UnRgciRrZKPd6HeUMs?cQ|veS-F4 zla{?^BIIvO>YZ<#Xc(f5Ao9E4)-&^ONV?j1PtnI1e(Q>sr?tc~%-|86^?Tx2MhVip zpSMVPk}cL5++=1&uXuaIk-uHgcKi$9de4fHKBL~OC#N(agqPsPDOUb&Nkzc3Eh=Fn zxlaHPe}AD^_>qezOlRTR%`;8QQA2TJJS6D-D{ze>dPIE*Y`8lPH2p#|a}D)B=QySm z=)k8uK(DO_d^$QifLX@?p7q|y(gCnoj@60y!=amZf#9fHD=|tG}7TT8=GEXrC#6h|1kzMrP8v=I7Lj(S~9`4|o`Ye`QMU z@^Wu))L@P>V0UyPW^646`Vsz7 zawC$JOP!e^fav!v-cWo{Z%gOao@N&4Jybzf86BnLk7y1>;9#NjH&Yw&6#cfw`P3}U}I`jx=3{WF3GqtjKST{<;jcX(*iP@$JgWG2p}I&m=%1KV@%uu%g_Bq z_JYz>_vQ`K`G6~yCT&=HXUN#GPhb;KbLEqHP3+(twMttvfhhf=BSj1z4Fxv09O;$p zqe8%W=TS|)oTKeuHuPj1D~*mqlw?Fde6Avw70YmGC4r3MY-{J!iLqjMtC-k4Y8ut# zN9(+}=dlmIV?85_Qu#^+d{>q&nD0!R_E6D)zOestU$g6ySF`!}YNG`eye0dDar+uU zOrmXHHS+g6PrYzntS-G?L@~j-rp3;~C@{jQxeAV#BaT>yM5rs$q(%1|!_05qhQvcP zB+Mj4a$R&#au8!cuDg`gxIlqSkSdP7LW;wRw2U@rTTTgChC(q%3RP4=#bYyyq!s7F zDK;Xt6)sPLXSMEb?GAu^@iDLGcfg}`;>Dc;J;>5@lR>SZrGL9zWTFZbpWB zzOc1f1-Vvi!(%(vxWl1Y6!4FzUB#B>#c^A%OZ>Iw_!nSfdG|T437B9;y37fs_|?Mx zmJay$e09E8!L+inSn<@8l}tzT;kD0pZ`nq1AAictJy+|s2QJv z6U=R;JBYwR&Ld^9Ky}NMx0h&j*SALe7QPoS7JrIF`bGKSywRfVWhUmH{ls%!S)M;w zQmMD%+^Bq}sVesmo`8hTKs|>=X3Krghu#NWV-2-0(HLzOF4JEC%t~&awxJ|p?y5y+0MKD1$IzSUkhLA&&g2gc18m4Hay^N8R1P}&m9**y#CvQvwP_eF zH{}3O91&bGURtpNZf}!4CiWh!x3(w=1^}?{D?@Jes<(L^IT+7H2^^8O{nWpaMd$T- zjn&O}!*8IP_7C%A%0#H3;}iNHq4u)ZEl9g!)c5PP08Xqtfe06S(qipfbRCh4^(Xs> zquNF6j$U*lLMvY0rqD71RF&F`RB2+IEZk&3jd%FgJXQ3$`+eCXZUQ_{@=aZ)HPqBBV!)7G&R%Z4$sV8NWDNM zia7BTcrYeD`E~cb3%w8>jMl1oVu(K<0{*+6_Th{Z#fejm^Ad2sH>e(&{yeeX7`5u3 z*8C4b5l7V>^{)l?*liC0XQA-8ThOMT<0k}4SiR*N&Ks{ohxS;x zKHIJeC~m6&)RNM_0O9avr0p~6RxE@2vx|RB#1!w?tU#CKg7i`JIVAfing>v-ZnbZ! zcXKiX^ypYm+%Pf+u|0QU_(Ad?mRS&(24rE2e!XkMeANAi@~%MxDphd~2b1Uhs4+h9 z@+<|4&lK$2>1faZJU)k2+C_m~Wm6{yjOG4fLj<|ncMf@|bguQWxXboW zA@$nH)?}j~DfIEN)chx-YGUL=0G#}$arlk*RaTzg*Wtw~1~E!6IVC1_Qy}<1J&usN z(PuzSKyBSr+wKLMSRBFzQy#PZg=a5pfmOh|<)o=5U=tO*+#^kc5~-UCV?mrIzLO`< z@@-n1PCtKvJfGgOdOG+N;1BpCoS#Ia*p5mvglyhgnj`(QwTh;kY#l3Z@q?Unr5%ZL zFKP#Yz1opec=e3#H!f;^?_d3k!I=V4ww3xgBA`@9hd3|#FzrHdZv z?{gN1!*Akt83VRyYy`*5-P;9m`?}vf#y{N%8{J{ST%p6GyMEZ1b8&Qj0&g5wPO5AX z(oeibjYh{Ay>%%l1i7FWRmq8+wm)=|MAplwt$r##%j;|U-%`j4IVy5tAsX#RQ+-UE zo@SroQKhZCAAd-(cYpm3l1E$Rbxp0+5k1Sc7yiBg8vc=eyYh}NXb14@aS`kDiYZ@4 zOo`g0+o!JYRvQr-<%{P@kjP=Bomif&AIGgJeUJ1X{@8vkK3pMqq%ZQB%+pj0+my2- zd*@rcuxQ zqgu3GrU$i|k%u}R?@rBrA+yr6aF{>q=V9l2ufp!ywAmMRji@NNE&9s0)E} zv~nvse+#s1qsw3A>wjs{EI#%Kg>V;x1-KGLEX?@QZ}x}qVso&(0F1Y94M2^~4Bph? zO>ugDik9rryMB{-^^5z|TLJp4YPhXa__-G|4zwtcG>*m(9Ck&kH>`SGym)gMmB_w` zNmS3Ni^4Ga7Vv6+~GSP(w6bx5uLfGn21Ucex_Dz z)v_csCt&-yd)L@-Ng%UQ-f!CYU8<4Gg7dc?TcQ&!*D|U##ynlJ6|PhH*d=Z}s}vd^ z8k36W^>a?6#iOS6mtfs?`b{fy{!_9_$~cgCSn<2)09rfZuj zUTH=>QF&tDCBw#N`;(aSx{HeHa0DXHs$ou)YrNFbLW~)If!ZI3*UWM!e67 z>hbAb*7QR#4?8uMgZ=C(Fxnhk;#nF%f-*-tFnyJq}}IiP5|rZd&oa5Y!HKCM^y(22JP3j&yx%l#^^v^ z{))5-uXJT`4RkH1X=B+c3g#i**vI;(v=1(!hH%#lQk}F0E|QpHnPT3T!9XOYiud6# zeB8Q1J}|^qpkaG>?^xG?rX}xcWc1YA_R%iyj<8<;;C1BaR{eOcF_B62y@TPiu%cY0 zE+={xhGt8pQ;iZD+aZrHi=|vra6n%BqgORB7zi`ZCk>8Ty>C>evVJ$$oUW-`csQOu z7;idcxR8AZ#d!hA;9=LL(QsW`5;PYPG%5;E!CDk4>$=J1c_E0r9Ny z>J=fR^@9E_7!cYKVW<@wV>djreIVh-t{+#o8JLPS=4MCH@>2TruFxLt?8v zFv4vgI))@L!b0;Qaq_`h9LMV+!6WJZLZb4*&UbOo{NiLjrm%T#^Y3>+j`wvnwn~v$Y$&E z(Qs+Ha*G$I+W*4ICaVc%)aPS7U$wA<4)jSrdL({(lrM$Se!@R^wW(4L%5R+VAL@S) zMUT(xIu_no&b}7s^L}+itv)6!rYIT+bP2EOdtAu9bXEHpVqA#kDE;3$a6^G7=4@!s z*uOL0Z#i^SWTTEv3LfP@ubZp{OIE~vZM>IdrJ$9CG6qPCud0rH?Z5EokHhV3DpaeH zLz{^dPfkjoa-$k}@YyP*ElRqY!;ZU$t>}FhdW@%oR-!!6CB@oI1ID`{)hz8Qj_Xh> z#AEt95mVdAHM|;aG?+Ub{LaE-0VS!1(RD@ptfbfHkz+_*he=bn6q||8TP9r)hwj@) ziWG1W&mqcKXyZT^aUgu9&=qx4cAnFY-4B|#k8Cskyn)7@du}${DKvYiDk=NF;cJ%; zR9v_<9S8J$jhqkzZ?+@jT|bEKa8HHb`Rt!fH&sK-z}jqyAoLMrZV=F2Wx1=@?*N2B zvcT4HJ?#HaSil-@?$Z>`=uO%yEYkyzyf2lbDljrAb>my>P=r*tulF}&0`@%#)J*xkeL}IRM5H#BmONLsE zS)I9-W3bhX?9olM9VK16y=}JgIO@@hL4F767Xrb$=QouZRM<3IEP;vSp=6tV(M){S;Eo_yd<5BIgp2J%yr?m7g@pijbWkKF*CQcc(0S6G_%Fs_HA(IJIbiCez& zkUy@J$?&2D#p)Wa+98skDWQlYJzszHKpWpH_e`+A2At-2Ctw3gI=2V=X})Uk&bRJO;Oas<$_i9~w%( zldW7laJA@c{|ZBeclSsuNN;h!?~*|8GVtLWj=8+q!#o$3^LXNASXBePy!G#M)Zs94 znb3#PErz4@izY%4(}i-Z#jk6PtMPB{pR`>PthZh> z`zeJE53{X3tY}skFpZgUngki=iFCJLDo;V_wv*wAzo)bWF*Z#wT3l)~m)Y*atBQEh znv15vYt1peMbL7|ncD_pBlMmnUJ-Mq#{L_F*n6N7g`q*Wd8D))WUuGyhbBO_2p?Zu z)l_(Y=gN16(){*yvJjzRfWRCYO@(tIz^vDEOuExdQs?#&{DA8eNI;Mei6`%F1(;V9wW+ zCzV|0OkupFY(U;nsW$4w)p{Hx`$s zCbisOByXhyPjO`MkHQtLKkb@#WEMu-y%>D{lbttoS-e3f{F=4=>?zO&uO~#=KQKUS z4&GxlcpSL-Pfh&qT{H;STMTo8pdjp*wP|n2$WUc=%o*v1WMk>%a~x0nWS9SJlFUtS zX=+4E=ZUHzsZ!H=(U^50f>iI&r>D0FnEz|!9)YL4M7*cSVi%c%JQ?D-aQ}{)jG%56 z=*0NeiNQImkoo-hTA&g6;q-dgVeTx#o5mtQfZ1Yxe|nD+iNA>hxTG}g+UTI8tbTu0 zdzedMPH?6HmdS^yP`b;}Bu_P3`g66SXI@Si%yIc>75Y#Q2JTp%Bb&1MzH$iM>1Fw+ z*iBvTpX#FXX(vvwwq%KChNygsH_}#C?22vBRx+3i232X;_y*y{|$!^i4ZE7s1+dzea?z$2B9H!J`TN0c`_@XP`q#3M{*b@GjQGI%HO)z;j9c{brX$ z>62kBZZ_fP$-B}l-wJ}GK-|H zYaHa8s%hU~<=01+#_&OmNQ5)bN$ufm{1#eGvCqND*AAXD>NDaw z3OaC6H0A1W+Bn4xkU{*$>Q(a8O9i1lA5pG^++SbOCNVoB*At5JU5a1M9LB(Ae08;-AH6_XXj<-ro~F&?KdpFw2p8 zX(@a`U^;LT*DzoUM2##MQlWB)f3mbyFavW;MUU9tC44@4!-z9s?Vp=oQ0h`&B(k@1 zAEf1_4GrMFAgkAc?%?uL=#@99L+(F3_Z+@2;k)VFCM8XT@DS*yLU3~cIXp2K3%Uj> z(B>dFigluD=I7QRWl7Cp64vQW+oJWa%}~z!{40Rtuxhx|x`f&v^~`rYlh~w6rM~tk zt-L&`5lI6XZkyEZ^PY@R(*ejTDWlo>pbT;E;CrinegAxy*jGpzW2aGWzjH2{)7@~> zkAKr6@}gbQEFxd2n&3cqF|~p9x)AOWvQTkdFDy?rNFq!=bTM!-?(JhxdZ50pmx9u| ztfx+@IM^VfUMp3JhYww^+@`MbimgGOqi5In-~3X1=q!G@J8%rZ*zfF^)1F*n2J-J8 zDC~>!JT7L8_!7?ITFyep-9;3OJdypU*i@untdmG8^oOYkMq{4MvsGvyu|=^U>&hQH z6_-tN`I$nH$|@MJ73nL;4t9G%O5FYRn!uFacyLau?)AE&MNaHjv{Eh%1AnSsYk#pY zU=&&8k=S4~Fe+^+j3AE1s6$MBk|%HzqiU}yNuS1u<|dlgGxHa2QMPb_3z0W1D);3~ zYyO%f8yq2zm70jlPN|2v5VvV>v~1YO19LHNhh4OF$rn}iQDfp$nlSJt57J2+hc)Oz zOBAous_HRZp={li1A2U3mKzaF&J?N{_ac*rjYW)ylm)Ww?Jo7 zy*kafgKwT^AH+N}#{G&M;@%q~oBGas7b++9t;F&P7dPy(y@ME#-`4zFB<9mWfw+HT z0SZ;npL;O=HfXDp?X_DtTkfbL^@>mjwYTOO${~#ADWB;k4)WjD3tuxa%~t`x@pd7)(W6qy>b-M8^%J;m}4+4g4aVn5%HC@({nn)iYNgf*4-rzOFN14hC~#-t&CYK4_G%;(-Y!`M z`j;=SwFod(!i13%u_PtuUQ!EizO3L(_^~y9v#244gAsx2%Ga@2wy5%aPjy^jAuW@UB~R4+_)%}vktH8Ll0YxBOeiwnPh{G7!EJ!IBD zkEwbM`s;0am1#F;uuJRqfeS)pRaxe5qmi*?0R(*0l=Y8cJY;6rs&;WywQNb2b{Z=e zZ>86=;4mjjGjvBe^w%ADfdoR26KxfE0}ru4U}j9IRwvac88$tKff}w(a&%Xf!xR8tDxcb7yjtqvdh=Sgy0-nH?o9wQWFoK^|B=Vns=j$oI@fC zy@vB@+Op48SNJ61aNUWh%-|CmQDMuMOXc&~mv09-(*8Ew@o;k5#(Y;oBYB(20MW9T z6v$O&{2XIOBb?#57QK#EC|IK>HgfXf99PscRPMVxsoc)q@2}AE@*^INyk>5yut4Z2 zp0bR0QsX|&``^|xjGiB~O&W6#ms?~S4?k-eUyS^e6bsfL-5Ht>`WGamvHElFGp2=8 z`fF%-nTD85*zC77;}buIN||Q*($AEAfI@sS%(Q|h-{&w0-$*I$xfr>xTy5;;tZJd=>5Ti#U1<|!VS)26R0Ij++_r-TnrQn#OUe>t9w_xfVDM|zR`;_d( zOcf?2OJC8@j?@S?aUy?3?P7|Wudz)0C3s(^a9H@{?!xiAlZcHEit*nfzXP+TyQW2- zy8q&tlH7b_$=rGQ?eTyuO1@X|Vr}B#k+ZE7mH_|tmq$f^^JBiVLqD$Z^HZC5=!$qP zmdYr#1X60}KZ;E${i1+482g*r6JNohAtee&Gs2K)gFY#i5dqEwJsS4nMCRW6A?Z4 zjvKh(ixbrO^b^K`@7?k8boTyB!;D}HS&VxT8p@ixQ2`nvt%ud;^%LuFVlxK3I(V0A z^G!p7UMU04tJl`T59KR}g$s!cQGpt829@>Jiz7wM_s25>#|Cht=ZM7n?d9#* zgpV^n@1?qnd9AY)0vn8!m&vf133x*m|9xu?m{X!pYaUGqzGa*o-3R!OTJllV|0m!& z%lRP;@llb~z_9`y!44T&*Y<;vG$7>^XMpMgnK(mjU&?ED^NcBf`dQ|`nD#w03TO>@ zH8yc~D^BZ-g?7vP)C^MBNw?)JVkk|`DPIvRbgoGoH9%3J6oQ9nVO`4Ni&a<<(0Fjz zLbdW*S75Z+=uT)se|_H89e%tW3w>i^!^$-O$7wkC7hpZg>%6bFBw(H};kDboHth0u z=sM~IUxd=};a8;Na-4xec}{)apuWYYt)4$MXKWv|<^cJaNZbc@JHjo)*Bu%X4t=WW zo{A2oqPXyCIaC;m?+pztczKjg6k3ouDc9r=rzvOiJO-pDwFavOkXfeq?glN|_L@M` z$zuY^Cb=0f-P$aU;)t;eolzA@yj-2Ow-D!rzNu4V|M)8t?7w_Sv_jn25{?Wdi#&m? zwCJH>WxP5J_`jSkFO)Y_qy{bSe#n%U!V%q{xaRAV=uZ-A`K%q(PlSGlp{Akm{U?Tn z0@mu7qD@8m=iD1Qwht`<4#mhC#bcpXZHzMusF#SUUk9~p(=91w zEr*O(vD-Z|u5hH^j$XUijsGx7NV1g(OQnwipFP(jv=jxUOQ{7AHvLPJ)~l>LGh$>i zw$vz;Km0Ey)+y%OmQli~Q(QOw4L%zHha+QTBzTbtYMjpy?f$s4AsWjLxZRlg^!V=+ z-W%kf2FYo_F8bLEXQ;-yFX@Mmc@%x;aL?9YUxiKOlpPsQ2Vp^aUkf&aoXMVF1cf&5 z-@PA`p-AkvyR@_bkVQ6(W{6RRF2)V79OT`bD45He+Mqy-WT9{w0;q9@HN>D=csP%n z0Q-vANO2@|<=Dfq_Kq>Y%kwysTv?KpViO}WC7n>Y_2ZDpe~1u^>@#q8a2_-B7IETU zP@^}O6B6i`FJngJ4O{G7SIj6m14B8)6t;-67_R^!5J;C`^$YDbw?BAH??BgYk@Cx4 zoWCz}gKQSm7B4gV?pCh6tJR=#_Q(>1^Xk>`=pz&e7|Vzl=TPb6J2Z;`rsQu;AJ~lw znc+S2W#mqZ=Ml5sJJ=cO1q0>x9?MQ`9+c!Q>|?7uhvuzGb~FL6U--Xgt^l=Se*62u zqz-KlgPOhT$T+-|k)Y3&_s3710hg8a<&s@1^PwHK3`Zd`-5Xp5ZIi`^$BM``|Jwh7 z5PEIP--;M6r97fyskJG9+A8Qchkv`&`p(mDt0i&#GP=b1J2WG|Tl`hZChT*4pOUv5 zFf7UX=d0V$n>;sLg8+}1n$yzvu061s!iOBnWbxC>f7-K)0^hGvw@#P}#?X1PF^@`v z4ODotLK2J=2L20c$xZrj_DPT4v$~}~7b7>|^*=80QJ1upCC~=`BShVZ{V07ryRnCQ zK+R(E;L-24&6YW8#D74>aix-XH&pE|gAbj6BOUlJBYwv6%DzqYflVXpD^?!m#316w z@Q@7lRQaCi8;jU>Yj|kMyX+ePUvsl~-yeeo*(_FTyD|D0ugx5bZN`B9cFev*09-Gz z3bm!{x)X2}gghD^%4pW?dS(3d;!20-Q{;tS8G);;| z2dsMby2!llYF~_%T#MeVnQq8z*lfh;;Rej^q|Lew!=>M+B1rD5QS7E~L^)a_gF!Iz!vK&aC#p75&pSNx1Mu6?Ke zLrYx)Ck-lnn~ytN+7x%yy)&iMro#vg)Ydq9a$3wfwN73!0`8UquW#`cz5zT~ssjWS z#nAw*T6Z-;beTDiKLj-h5Y+nWIn|Ih&b@(wy^cqZO|yV=HQLixcO(++% zBD0gIUVf8a*v)^r0KYcy*=&YDxe${R(vtdkk@UO)C?U5jm-cgckX_AZA;2w{QFEbhpz1 zxAifN9(Svj{}6m29N zl(aq4Vlv%q#VTTkfS9{l(f~vf!b4m*PG2dMiWc&um_PiS$5n4#AOBlqVe=3PqzgoL z1L|0%s+`m1iO*QhV6bUN!{y_-R35bCSD7w#<-!`!Ns^p$9&)gynmKRmNfO0vJ}+4+H**wCgb|(n<-&bG(^vM$%B(q1E&f)~)6{tG#G;eyuz8 zaEq?}(!r?L=|Vw|WK9r(lLD_ei-1YvYI1iyEm*hOw=2{Mh}cjBs!?mSdt^k#uy=4d zQd7F?ESW<2wHLHz64yr~cnO%s>2CjgS7cHX$$jcHvS0&~-oi!Y1 zQZ{3tec>5VDB*u*12pCaGZIb9^=3d2i#5VzQJ< zC*1Hl&I0aGNJ)x(9D$g`S#eGsa1eki~ z^IMN%XWy5g$-AQ3*hz?Vg!@+m?27D*VM9Fj!3V2gOLod-Gy}C%p|5MC&}5-q(}0=) z;E{DRob!;`<*8X@_OG0=+@_`&W<5M9Uj=g^*ucqPuU5qGx;$)T< zG@&#J8<~CAXT^F}2oT_Y!uM1{ZTmensq1ril#FIE8dc>!o3`OiI(e(9EuVK|(|g`P zVTqgSM=M1EM%2?-76aJj^z;qrAs?X4x(4JNABkXe`Q*A*!BOh6G-YGBv5xkPwJ@n)aJG5Ncjg9M;sm1cTBHiUO=+2v9UJtSubZwR#H^R!U>*Dj2il z>5@<3d5q!_V0+S0GSl8x(l!0KX+>$OV}%DJqfEoQLT(m2=oz95{I7>yV4UP7`aenu zQe~h7L+U1vFaRP7*3*Yb!M*^hs)UJV=E0VP&>vATv6O=}p+5jcU>d?0Jy+c(r(hz= zf4cj>fW{391^%!oXtml}k0jGl+@#k%gM6?Czvp{JIoj=igP#17Y~vY}YtAgZ3gU;&>u zH?zfoe+hwV8KkS2_jXTCu9$pA158^gs2-{Toie2f6C`tuiHiAX^^&cKLYXH Date: Mon, 12 Feb 2024 17:36:54 +0300 Subject: [PATCH 099/136] 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 b0c17c8356..f8d89119a4 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"; From f359d2e5eca9abda77c4072006b774c3faae52c7 Mon Sep 17 00:00:00 2001 From: sleepycatcoding Date: Sun, 11 Feb 2024 14:49:09 +0000 Subject: [PATCH 100/136] 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 c78ffa28c3..977307b065 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", 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: [PATCH 101/136] 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 f6854157a6..095bc9ed30 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) From 749209ce6c7229e68af460673a54597e9f0167dd Mon Sep 17 00:00:00 2001 From: mikikuzmanoski Date: Mon, 12 Feb 2024 19:55:57 +0000 Subject: [PATCH 102/136] 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 cbccad87ff..7ef9079188 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": "Оштетен слух" } 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: [PATCH 103/136] 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 839bebb96a..1ae7658eb5 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 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: [PATCH 104/136] 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 1ae7658eb5..f8012e90e0 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 From 6c1025f2cd947979130404f115a3a51a63df6988 Mon Sep 17 00:00:00 2001 From: gearoidkeane Date: Thu, 15 Feb 2024 15:47:44 -0500 Subject: [PATCH 105/136] 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 0000000000..0967ef424b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -0,0 +1 @@ +{} From 2bd85df383ec70251a8cdf9802963e29b52b60a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20L=C3=BCtzner?= Date: Thu, 15 Feb 2024 22:15:14 +0000 Subject: [PATCH 106/136] 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 a8ee693ec4..55642e4e21 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 7b510a3379..90035f18f1 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 ccdf017580..a18a85ec0f 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")] From 4d93f067320a83acd76f445fb0aabf828922e140 Mon Sep 17 00:00:00 2001 From: gearoidkeane Date: Thu, 15 Feb 2024 20:55:06 +0000 Subject: [PATCH 107/136] 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 0967ef424b..28e54bff57 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -1 +1,3 @@ -{} +{ + "Albums": "Albaim" +} From 5b93aec2f5814f92acf3a517baf52c6e1ee952db Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sat, 17 Feb 2024 14:29:34 +0100 Subject: [PATCH 108/136] Always make userId query parameter optional --- .../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 6f0006832b..1cad663264 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 bea545cfda..742012b71e 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 921cc6031f..0e7c3f1556 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; From fd957ec7f46f9b7b75b19958bdee371b2ae45862 Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sun, 18 Feb 2024 13:18:16 +0100 Subject: [PATCH 109/136] 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 da68c72c99..90fedecd61 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 6a30de5e6c..8482b1cf1c 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. From aa3aaa94fe14554354e2897fe95cf5cd2bf2ca6f Mon Sep 17 00:00:00 2001 From: Nyanmisaka Date: Wed, 21 Feb 2024 01:49:39 +0800 Subject: [PATCH 110/136] 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 bb867aba30..b6738e7cc1 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); } 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: [PATCH 111/136] 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 97173d1968..0370ed055c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,7 @@ - + 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: [PATCH 112/136] 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 0370ed055c..9758d08b03 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,7 @@ - + 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: [PATCH 113/136] 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 9758d08b03..00cd5e9205 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -84,8 +84,8 @@ - + - + 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: [PATCH 114/136] 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 81fe5add42..d9b689bb64 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 00cd5e9205..e20d6b6295 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 af309b0831..6bd7d312c3 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 75a6d1e649..f1dc492de0 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 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: [PATCH 115/136] 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 e20d6b6295..1d7ebfaf46 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + From dfe82a74720d2592eda1631ee5e7a90292df2903 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 12:05:08 -0500 Subject: [PATCH 116/136] Use DI for timer managers --- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 90 +++++++++---------- .../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 e19d2c5911..a850ad6ebe 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 2ebe60b296..8a3fa7f36f 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 37b1fa14cc..59ffa5d80a 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 e4800a0312..b7ea5f54b7 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(); From e13ccfe8547f7f3fbe01dc9ae378bf693f27c4bc Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 12:07:11 -0500 Subject: [PATCH 117/136] Move timer services to separate folder --- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 1 + .../Extensions/LiveTvServiceCollectionExtensions.cs | 2 +- src/Jellyfin.LiveTv/{EmbyTV => Timers}/ItemDataProvider.cs | 2 +- src/Jellyfin.LiveTv/{EmbyTV => Timers}/SeriesTimerManager.cs | 2 +- src/Jellyfin.LiveTv/{EmbyTV => Timers}/TimerManager.cs | 3 ++- 5 files changed, 6 insertions(+), 4 deletions(-) rename src/Jellyfin.LiveTv/{EmbyTV => Timers}/ItemDataProvider.cs (99%) rename src/Jellyfin.LiveTv/{EmbyTV => Timers}/SeriesTimerManager.cs (96%) rename src/Jellyfin.LiveTv/{EmbyTV => Timers}/TimerManager.cs (98%) diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index a850ad6ebe..48f5cea84c 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/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index b7ea5f54b7..a632827f1f 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/EmbyTV/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs similarity index 99% rename from src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs rename to src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs index 547ffeb668..18e4810a25 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs +++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs @@ -9,7 +9,7 @@ using System.Text.Json; using Jellyfin.Extensions.Json; using Microsoft.Extensions.Logging; -namespace Jellyfin.LiveTv.EmbyTV +namespace Jellyfin.LiveTv.Timers { public class ItemDataProvider where T : class diff --git a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs similarity index 96% rename from src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs rename to src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs index 8a3fa7f36f..6e8444ba26 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs +++ b/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs @@ -6,7 +6,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.LiveTv; using Microsoft.Extensions.Logging; -namespace Jellyfin.LiveTv.EmbyTV +namespace Jellyfin.LiveTv.Timers { public class SeriesTimerManager : ItemDataProvider { diff --git a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs similarity index 98% rename from src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs rename to src/Jellyfin.LiveTv/Timers/TimerManager.cs index 59ffa5d80a..6bcbd3324f 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs +++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs @@ -7,12 +7,13 @@ 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.EmbyTV +namespace Jellyfin.LiveTv.Timers { public class TimerManager : ItemDataProvider { From ca1a8ced48747dc3ec90f8d3d350246ad119d45a Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 9 Feb 2024 09:56:04 -0500 Subject: [PATCH 118/136] Move IO code to separate folder --- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 1 + .../Extensions/LiveTvServiceCollectionExtensions.cs | 1 + src/Jellyfin.LiveTv/{EmbyTV => IO}/DirectRecorder.cs | 2 +- src/Jellyfin.LiveTv/{EmbyTV => IO}/EncodedRecorder.cs | 2 +- src/Jellyfin.LiveTv/{ => IO}/ExclusiveLiveStream.cs | 2 +- src/Jellyfin.LiveTv/{EmbyTV => IO}/IRecorder.cs | 2 +- src/Jellyfin.LiveTv/{ => IO}/StreamHelper.cs | 2 +- src/Jellyfin.LiveTv/LiveTvManager.cs | 1 + 8 files changed, 8 insertions(+), 5 deletions(-) rename src/Jellyfin.LiveTv/{EmbyTV => IO}/DirectRecorder.cs (99%) rename src/Jellyfin.LiveTv/{EmbyTV => IO}/EncodedRecorder.cs (99%) rename src/Jellyfin.LiveTv/{ => IO}/ExclusiveLiveStream.cs (97%) rename src/Jellyfin.LiveTv/{EmbyTV => IO}/IRecorder.cs (97%) rename src/Jellyfin.LiveTv/{ => IO}/StreamHelper.cs (99%) diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 48f5cea84c..cfd142d438 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/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index a632827f1f..4f05a85e43 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/EmbyTV/DirectRecorder.cs b/src/Jellyfin.LiveTv/IO/DirectRecorder.cs similarity index 99% rename from src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs rename to src/Jellyfin.LiveTv/IO/DirectRecorder.cs index 2a25218b63..c4ec6de401 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/DirectRecorder.cs @@ -12,7 +12,7 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; -namespace Jellyfin.LiveTv.EmbyTV +namespace Jellyfin.LiveTv.IO { public sealed class DirectRecorder : IRecorder { diff --git a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs similarity index 99% rename from src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs rename to src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index 132a5fc516..ff00c89997 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -23,7 +23,7 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; -namespace Jellyfin.LiveTv.EmbyTV +namespace Jellyfin.LiveTv.IO { public class EncodedRecorder : IRecorder { diff --git a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs b/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs similarity index 97% rename from src/Jellyfin.LiveTv/ExclusiveLiveStream.cs rename to src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs index 9d442e20cc..394b9cf11d 100644 --- a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs +++ b/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs @@ -11,7 +11,7 @@ using System.Threading.Tasks; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; -namespace Jellyfin.LiveTv +namespace Jellyfin.LiveTv.IO { public sealed class ExclusiveLiveStream : ILiveStream { diff --git a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs b/src/Jellyfin.LiveTv/IO/IRecorder.cs similarity index 97% rename from src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs rename to src/Jellyfin.LiveTv/IO/IRecorder.cs index 7ed42e2634..ab45064142 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/IRecorder.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; -namespace Jellyfin.LiveTv.EmbyTV +namespace Jellyfin.LiveTv.IO { public interface IRecorder : IDisposable { diff --git a/src/Jellyfin.LiveTv/StreamHelper.cs b/src/Jellyfin.LiveTv/IO/StreamHelper.cs similarity index 99% rename from src/Jellyfin.LiveTv/StreamHelper.cs rename to src/Jellyfin.LiveTv/IO/StreamHelper.cs index e9644e95e7..7947807baa 100644 --- a/src/Jellyfin.LiveTv/StreamHelper.cs +++ b/src/Jellyfin.LiveTv/IO/StreamHelper.cs @@ -7,7 +7,7 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.IO; -namespace Jellyfin.LiveTv +namespace Jellyfin.LiveTv.IO { public class StreamHelper : IStreamHelper { diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 1b69fd7fdd..6b4ce6f7c1 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; From 7baf2d6c6bdaa51c3ecd0d628d36a0dacbd2bc54 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 8 Feb 2024 12:28:27 -0500 Subject: [PATCH 119/136] 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 cfd142d438..d1688dfd9b 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 4f05a85e43..d02be31cfa 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 0000000000..0a71a4d46e --- /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); + } +} From 0370167b8d1a8c7616d5bc15d823c3c187aae2cc Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 9 Feb 2024 13:46:28 -0500 Subject: [PATCH 120/136] Add IRecordingsManager service --- .../ApplicationHost.cs | 2 +- Emby.Server.Implementations/Dto/DtoService.cs | 8 +- Jellyfin.Api/Controllers/LiveTvController.cs | 7 +- MediaBrowser.Controller/Entities/Video.cs | 4 +- .../LiveTv/ILiveTvManager.cs | 4 - .../LiveTv/IRecordingsManager.cs | 55 ++ src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 913 +----------------- src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs | 20 +- .../LiveTvServiceCollectionExtensions.cs | 2 + src/Jellyfin.LiveTv/Guide/GuideManager.cs | 6 +- src/Jellyfin.LiveTv/LiveTvManager.cs | 21 +- .../LiveTvMediaSourceProvider.cs | 6 +- .../Recordings/RecordingsManager.cs | 849 ++++++++++++++++ .../MediaInfo/AudioResolverTests.cs | 2 +- .../MediaInfo/MediaInfoResolverTests.cs | 2 +- .../MediaInfo/SubtitleResolverTests.cs | 2 +- 16 files changed, 992 insertions(+), 911 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 550c16b4c4..745753440d 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -630,7 +630,7 @@ namespace Emby.Server.Implementations BaseItem.FileSystem = Resolve(); BaseItem.UserDataManager = Resolve(); BaseItem.ChannelManager = Resolve(); - Video.LiveTvManager = Resolve(); + Video.RecordingsManager = Resolve(); Folder.UserViewManager = Resolve(); UserView.TVSeriesManager = Resolve(); UserView.CollectionManager = Resolve(); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index d0d5bb81c1..d372277e0a 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -47,6 +47,7 @@ namespace Emby.Server.Implementations.Dto private readonly IImageProcessor _imageProcessor; private readonly IProviderManager _providerManager; + private readonly IRecordingsManager _recordingsManager; private readonly IApplicationHost _appHost; private readonly IMediaSourceManager _mediaSourceManager; @@ -62,6 +63,7 @@ namespace Emby.Server.Implementations.Dto IItemRepository itemRepo, IImageProcessor imageProcessor, IProviderManager providerManager, + IRecordingsManager recordingsManager, IApplicationHost appHost, IMediaSourceManager mediaSourceManager, Lazy livetvManagerFactory, @@ -74,6 +76,7 @@ namespace Emby.Server.Implementations.Dto _itemRepo = itemRepo; _imageProcessor = imageProcessor; _providerManager = providerManager; + _recordingsManager = recordingsManager; _appHost = appHost; _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; @@ -256,8 +259,7 @@ namespace Emby.Server.Implementations.Dto dto.Etag = item.GetEtag(user); } - var liveTvManager = LivetvManager; - var activeRecording = liveTvManager.GetActiveRecordingInfo(item.Path); + var activeRecording = _recordingsManager.GetActiveRecordingInfo(item.Path); if (activeRecording is not null) { dto.Type = BaseItemKind.Recording; @@ -270,7 +272,7 @@ namespace Emby.Server.Implementations.Dto dto.Name = dto.SeriesName; } - liveTvManager.AddInfoToRecordingDto(item, dto, activeRecording, user); + LivetvManager.AddInfoToRecordingDto(item, dto, activeRecording, user); } return dto; diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 7f4cad951e..78dd7a71cb 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -46,6 +46,7 @@ public class LiveTvController : BaseJellyfinApiController private readonly IGuideManager _guideManager; private readonly ITunerHostManager _tunerHostManager; private readonly IListingsManager _listingsManager; + private readonly IRecordingsManager _recordingsManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; @@ -61,6 +62,7 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -73,6 +75,7 @@ public class LiveTvController : BaseJellyfinApiController IGuideManager guideManager, ITunerHostManager tunerHostManager, IListingsManager listingsManager, + IRecordingsManager recordingsManager, IUserManager userManager, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, @@ -85,6 +88,7 @@ public class LiveTvController : BaseJellyfinApiController _guideManager = guideManager; _tunerHostManager = tunerHostManager; _listingsManager = listingsManager; + _recordingsManager = recordingsManager; _userManager = userManager; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; @@ -1140,8 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesVideoFile] public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) { - var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); - + var path = _recordingsManager.GetActiveRecordingPath(recordingId); if (string.IsNullOrWhiteSpace(path)) { return NotFound(); diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 5adadec390..04f47b729d 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -171,7 +171,7 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0; - public static ILiveTvManager LiveTvManager { get; set; } + public static IRecordingsManager RecordingsManager { get; set; } [JsonIgnore] public override SourceType SourceType @@ -334,7 +334,7 @@ namespace MediaBrowser.Controller.Entities protected override bool IsActiveRecording() { - return LiveTvManager.GetActiveRecordingInfo(Path) is not null; + return RecordingsManager.GetActiveRecordingInfo(Path) is not null; } public override bool CanDelete() diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 0ac0699a37..ed08cdc476 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -245,10 +245,6 @@ namespace MediaBrowser.Controller.LiveTv /// The user. void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user); - string GetEmbyTvActiveRecordingPath(string id); - - ActiveRecordingInfo GetActiveRecordingInfo(string path); - void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null); Task GetRecordingFoldersAsync(User user); diff --git a/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs b/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs new file mode 100644 index 0000000000..b918e2931b --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.LiveTv; + +/// +/// Service responsible for managing LiveTV recordings. +/// +public interface IRecordingsManager +{ + /// + /// Gets the path for the provided timer id. + /// + /// The timer id. + /// The recording path, or null if none exists. + string? GetActiveRecordingPath(string id); + + /// + /// Gets the information for an active recording. + /// + /// The recording path. + /// The , or null if none exists. + ActiveRecordingInfo? GetActiveRecordingInfo(string path); + + /// + /// Gets the recording folders. + /// + /// The for each recording folder. + IEnumerable GetRecordingFolders(); + + /// + /// Ensures that the recording folders all exist, and removes unused folders. + /// + /// Task. + Task CreateRecordingFolders(); + + /// + /// Cancels the recording with the provided timer id, if one is active. + /// + /// The timer id. + /// The timer. + void CancelRecording(string timerId, TimerInfo? timer); + + /// + /// Records a stream. + /// + /// The recording info. + /// The channel associated with the recording timer. + /// The time to stop recording. + /// Task representing the recording process. + Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate); +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index d1688dfd9b..06a0ea4e9d 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -3,264 +3,77 @@ #pragma warning disable CS1591 using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; -using Jellyfin.LiveTv.IO; -using Jellyfin.LiveTv.Recordings; using Jellyfin.LiveTv.Timers; -using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; namespace Jellyfin.LiveTv.EmbyTV { - public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable + public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds { + public const string ServiceName = "Emby"; + private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; private readonly ITunerHostManager _tunerHostManager; - private readonly IFileSystem _fileSystem; - private readonly ILibraryMonitor _libraryMonitor; - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IStreamHelper _streamHelper; private readonly IListingsManager _listingsManager; + private readonly IRecordingsManager _recordingsManager; + private readonly ILibraryManager _libraryManager; private readonly LiveTvDtoService _tvDtoService; private readonly TimerManager _timerManager; - private readonly ItemDataProvider _seriesTimerManager; - private readonly RecordingsMetadataManager _recordingsMetadataManager; - - private readonly ConcurrentDictionary _activeRecordings = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1); - - private bool _disposed; + private readonly SeriesTimerManager _seriesTimerManager; public EmbyTV( - IStreamHelper streamHelper, - IMediaSourceManager mediaSourceManager, ILogger logger, - IHttpClientFactory httpClientFactory, IServerConfigurationManager config, ITunerHostManager tunerHostManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor, - IProviderManager providerManager, - IMediaEncoder mediaEncoder, IListingsManager listingsManager, + IRecordingsManager recordingsManager, + ILibraryManager libraryManager, LiveTvDtoService tvDtoService, TimerManager timerManager, - SeriesTimerManager seriesTimerManager, - RecordingsMetadataManager recordingsMetadataManager) + SeriesTimerManager seriesTimerManager) { - Current = this; - _logger = logger; - _httpClientFactory = httpClientFactory; _config = config; - _fileSystem = fileSystem; _libraryManager = libraryManager; - _libraryMonitor = libraryMonitor; - _providerManager = providerManager; - _mediaEncoder = mediaEncoder; _tunerHostManager = tunerHostManager; - _mediaSourceManager = mediaSourceManager; - _streamHelper = streamHelper; _listingsManager = listingsManager; + _recordingsManager = recordingsManager; _tvDtoService = tvDtoService; _timerManager = timerManager; _seriesTimerManager = seriesTimerManager; - _recordingsMetadataManager = recordingsMetadataManager; _timerManager.TimerFired += OnTimerManagerTimerFired; - _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; } public event EventHandler> TimerCreated; public event EventHandler> TimerCancelled; - public static EmbyTV Current { get; private set; } - /// - public string Name => "Emby"; - - public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); + public string Name => ServiceName; /// public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; - private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); - - private string RecordingPath - { - get - { - var path = _config.GetLiveTvConfiguration().RecordingPath; - - return string.IsNullOrWhiteSpace(path) - ? DefaultRecordingPath - : path; - } - } - - private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) - { - if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) - { - await CreateRecordingFolders().ConfigureAwait(false); - } - } - - public Task Start() - { - _timerManager.RestartTimers(); - - return CreateRecordingFolders(); - } - - internal async Task CreateRecordingFolders() - { - try - { - var recordingFolders = GetRecordingFolders().ToArray(); - var virtualFolders = _libraryManager.GetVirtualFolders(); - - var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); - - var pathsAdded = new List(); - - foreach (var recordingFolder in recordingFolders) - { - var pathsToCreate = recordingFolder.Locations - .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i))) - .ToList(); - - if (pathsToCreate.Count == 0) - { - continue; - } - - var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); - - var libraryOptions = new LibraryOptions - { - PathInfos = mediaPathInfos - }; - try - { - await _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating virtual folder"); - } - - pathsAdded.AddRange(pathsToCreate); - } - - var config = _config.GetLiveTvConfiguration(); - - var pathsToRemove = config.MediaLocationsCreated - .Except(recordingFolders.SelectMany(i => i.Locations)) - .ToList(); - - if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) - { - pathsAdded.InsertRange(0, config.MediaLocationsCreated); - config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - _config.SaveConfiguration("livetv", config); - } - - foreach (var path in pathsToRemove) - { - await RemovePathFromLibraryAsync(path).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating recording folders"); - } - } - - private async Task RemovePathFromLibraryAsync(string path) - { - _logger.LogDebug("Removing path from library: {0}", path); - - var requiresRefresh = false; - var virtualFolders = _libraryManager.GetVirtualFolders(); - - foreach (var virtualFolder in virtualFolders) - { - if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (virtualFolder.Locations.Length == 1) - { - // remove entire virtual folder - try - { - await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error removing virtual folder"); - } - } - else - { - try - { - _libraryManager.RemoveMediaPath(virtualFolder.Name, path); - requiresRefresh = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error removing media path"); - } - } - } - - if (requiresRefresh) - { - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); - } - } - public async Task RefreshSeriesTimers(CancellationToken cancellationToken) { var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); @@ -279,9 +92,9 @@ namespace Jellyfin.LiveTv.EmbyTV foreach (var timer in timers) { - if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id)) + if (DateTime.UtcNow > timer.EndDate && _recordingsManager.GetActiveRecordingPath(timer.Id) is null) { - OnTimerOutOfDate(timer); + _timerManager.Delete(timer); continue; } @@ -293,7 +106,7 @@ namespace Jellyfin.LiveTv.EmbyTV var program = GetProgramInfoFromCache(timer); if (program is null) { - OnTimerOutOfDate(timer); + _timerManager.Delete(timer); continue; } @@ -302,11 +115,6 @@ namespace Jellyfin.LiveTv.EmbyTV } } - private void OnTimerOutOfDate(TimerInfo timer) - { - _timerManager.Delete(timer); - } - private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) { var channels = new List(); @@ -384,11 +192,7 @@ namespace Jellyfin.LiveTv.EmbyTV } } - if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) - { - activeRecordingInfo.Timer = timer; - activeRecordingInfo.CancellationTokenSource.Cancel(); - } + _recordingsManager.CancelRecording(timerId, timer); } public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) @@ -544,7 +348,7 @@ namespace Jellyfin.LiveTv.EmbyTV } // Only update if not currently active - if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _)) + if (_recordingsManager.GetActiveRecordingPath(updatedTimer.Id) is null) { existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; @@ -584,40 +388,6 @@ namespace Jellyfin.LiveTv.EmbyTV existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; } - public string GetActiveRecordingPath(string id) - { - if (_activeRecordings.TryGetValue(id, out var info)) - { - return info.Path; - } - - return null; - } - - public ActiveRecordingInfo GetActiveRecordingInfo(string path) - { - if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) - { - return null; - } - - foreach (var (_, recordingInfo) in _activeRecordings) - { - if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) - { - var timer = recordingInfo.Timer; - if (timer.Status != RecordingStatus.InProgress) - { - return null; - } - - return recordingInfo; - } - } - - return null; - } - public Task> GetTimersAsync(CancellationToken cancellationToken) { var excludeStatues = new List @@ -775,11 +545,10 @@ namespace Jellyfin.LiveTv.EmbyTV try { var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); - if (recordingEndDate <= DateTime.UtcNow) { _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); - OnTimerOutOfDate(timer); + _timerManager.Delete(timer); return; } @@ -790,14 +559,31 @@ 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."); + 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) { @@ -808,575 +594,12 @@ namespace Jellyfin.LiveTv.EmbyTV } } - 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"); - } - - 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(); - - recordPath = Path.Combine(recordPath, folderName); - } - else if (timer.IsKids) - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "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(); - - recordPath = Path.Combine(recordPath, folderName); - } - else if (timer.IsSports) - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Sports"); - } - - recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); - } - else - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Other"); - } - - recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); - } - - var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; - - 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); - - LiveTvProgram programInfo = null; - - if (!string.IsNullOrWhiteSpace(timer.ProgramId)) - { - programInfo = GetProgramInfoFromCache(timer); - } - - if (programInfo is null) - { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); - programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); - } - - if (programInfo is not null) - { - CopyProgramInfoToTimerInfo(programInfo, timer); - } - - var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); - var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); - - var channelItem = GetLiveTvChannel(timer); - - string liveStreamId = null; - RecordingStatus recordingStatus; - try - { - var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); - - var mediaStreamInfo = allMediaSources[0]; - IDirectStreamProvider directStreamProvider = null; - - if (mediaStreamInfo.RequiresOpening) - { - var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( - new LiveStreamRequest - { - ItemId = channelItem.Id, - OpenToken = mediaStreamInfo.OpenToken - }, - CancellationToken.None).ConfigureAwait(false); - - mediaStreamInfo = liveStreamResponse.Item1.MediaSource; - liveStreamId = mediaStreamInfo.LiveStreamId; - directStreamProvider = liveStreamResponse.Item2; - } - - using var recorder = GetRecorder(mediaStreamInfo); - - recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); - recordPath = EnsureFileUnique(recordPath, timer.Id); - - _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); - - var duration = recordingEndDate - DateTime.UtcNow; - - _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); - - _logger.LogInformation("Writing file to: {Path}", recordPath); - - Action onStarted = async () => - { - activeRecordingInfo.Path = recordPath; - - _activeRecordings.TryAdd(timer.Id, activeRecordingInfo); - - timer.Status = RecordingStatus.InProgress; - _timerManager.AddOrUpdate(timer, false); - - await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); - - await CreateRecordingFolders().ConfigureAwait(false); - - TriggerRefresh(recordPath); - await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); - }; - - await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); - - recordingStatus = RecordingStatus.Completed; - _logger.LogInformation("Recording completed: {RecordPath}", recordPath); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Recording stopped: {RecordPath}", recordPath); - recordingStatus = RecordingStatus.Completed; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording to {RecordPath}", recordPath); - recordingStatus = RecordingStatus.Error; - } - - if (!string.IsNullOrWhiteSpace(liveStreamId)) - { - try - { - await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream"); - } - } - - DeleteFileIfEmpty(recordPath); - - TriggerRefresh(recordPath); - _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false); - - _activeRecordings.TryRemove(timer.Id, out _); - - if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) - { - const int RetryIntervalSeconds = 60; - _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); - - timer.Status = RecordingStatus.New; - timer.PrePaddingSeconds = 0; - timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); - timer.RetryCount++; - _timerManager.AddOrUpdate(timer); - } - else if (File.Exists(recordPath)) - { - timer.RecordingPath = recordPath; - timer.Status = RecordingStatus.Completed; - _timerManager.AddOrUpdate(timer, false); - OnSuccessfulRecording(timer, recordPath); - } - else - { - _timerManager.Delete(timer); - } - } - - private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) - { - if (timer.IsSeries) - { - if (timer.SeriesProviderIds.Count == 0) - { - return null; - } - - var query = new RemoteSearchQuery() - { - SearchInfo = new SeriesInfo - { - ProviderIds = timer.SeriesProviderIds, - Name = timer.Name, - MetadataCountryCode = _config.Configuration.MetadataCountryCode, - MetadataLanguage = _config.Configuration.PreferredMetadataLanguage - } - }; - - var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); - - return results.FirstOrDefault(); - } - - return null; - } - - private void DeleteFileIfEmpty(string path) - { - var file = _fileSystem.GetFileInfo(path); - - if (file.Exists && file.Length == 0) - { - try - { - _fileSystem.DeleteFile(path); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); - } - } - } - - private void TriggerRefresh(string path) - { - _logger.LogInformation("Triggering refresh on {Path}", path); - - var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); - - if (item is not null) - { - _logger.LogInformation("Refreshing recording parent {Path}", item.Path); - - _providerManager.QueueRefresh( - item.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - RefreshPaths = new string[] - { - path, - Path.GetDirectoryName(path), - Path.GetDirectoryName(Path.GetDirectoryName(path)) - } - }, - RefreshPriority.High); - } - } - - private BaseItem GetAffectedBaseItem(string path) - { - BaseItem item = null; - - var parentPath = Path.GetDirectoryName(path); - - while (item is null && !string.IsNullOrEmpty(path)) - { - item = _libraryManager.FindByPath(path, null); - - path = Path.GetDirectoryName(path); - } - - if (item is not null) - { - if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) - { - var parentItem = item.GetParent(); - if (parentItem is not null && parentItem is not AggregateFolder) - { - item = parentItem; - } - } - } - - return item; - } - - private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath) - { - if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) - { - return; - } - - if (string.IsNullOrWhiteSpace(seriesPath)) - { - return; - } - - var seriesTimerId = timer.SeriesTimerId; - var seriesTimer = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); - - if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) - { - return; - } - - if (_disposed) - { - return; - } - - using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false)) - { - if (_disposed) - { - return; - } - - var timersToDelete = _timerManager.GetAll() - .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath)) - .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(i => i.EndDate) - .Where(i => File.Exists(i.RecordingPath)) - .Skip(seriesTimer.KeepUpTo - 1) - .ToList(); - - DeleteLibraryItemsForTimers(timersToDelete); - - if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) - { - return; - } - - var episodesToDelete = librarySeries.GetItemList( - new InternalItemsQuery - { - OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, - IsVirtualItem = false, - IsFolder = false, - Recursive = true, - DtoOptions = new DtoOptions(true) - }) - .Where(i => i.IsFileProtocol && File.Exists(i.Path)) - .Skip(seriesTimer.KeepUpTo - 1) - .ToList(); - - foreach (var item in episodesToDelete) - { - try - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = true - }, - true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting item"); - } - } - } - } - - private void DeleteLibraryItemsForTimers(List timers) - { - foreach (var timer in timers) - { - if (_disposed) - { - return; - } - - try - { - DeleteLibraryItemForTimer(timer); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting recording"); - } - } - } - - private void DeleteLibraryItemForTimer(TimerInfo timer) - { - var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); - - if (libraryItem is not null) - { - _libraryManager.DeleteItem( - libraryItem, - new DeleteOptions - { - DeleteFileLocation = true - }, - true); - } - else if (File.Exists(timer.RecordingPath)) - { - _fileSystem.DeleteFile(timer.RecordingPath); - } - - _timerManager.Delete(timer); - } - - private string EnsureFileUnique(string path, string timerId) - { - var originalPath = path; - var index = 1; - - while (FileExists(path, timerId)) - { - var parent = Path.GetDirectoryName(originalPath); - var name = Path.GetFileNameWithoutExtension(originalPath); - name += " - " + index.ToString(CultureInfo.InvariantCulture); - - path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath)); - index++; - } - - return path; - } - - private bool FileExists(string path, string timerId) - { - if (File.Exists(path)) - { - return true; - } - - return _activeRecordings - .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); - } - - private IRecorder GetRecorder(MediaSourceInfo mediaSource) - { - if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) - { - return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); - } - - return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); - } - - private void OnSuccessfulRecording(TimerInfo timer, string path) - { - PostProcessRecording(timer, path); - } - - private void PostProcessRecording(TimerInfo timer, string path) - { - var options = _config.GetLiveTvConfiguration(); - if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) - { - return; - } - - try - { - var process = new Process - { - StartInfo = new ProcessStartInfo - { - Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments), - CreateNoWindow = true, - ErrorDialog = false, - FileName = options.RecordingPostProcessor, - WindowStyle = ProcessWindowStyle.Hidden, - UseShellExecute = false - }, - EnableRaisingEvents = true - }; - - _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); - - process.Exited += OnProcessExited; - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error running recording post processor"); - } - } - - private static string GetPostProcessArguments(string path, string arguments) - { - return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase); - } - - private void OnProcessExited(object sender, EventArgs e) - { - using (var process = (Process)sender) - { - _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); - } - } - private LiveTvProgram GetProgramInfoFromCache(string programId) { var query = new InternalItemsQuery @@ -1512,7 +735,8 @@ namespace Jellyfin.LiveTv.EmbyTV // Only update if not currently active - test both new timer and existing in case Id's are different // Id's could be different if the timer was created manually prior to series timer creation - else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) + else if (_recordingsManager.GetActiveRecordingPath(timer.Id) is null + && _recordingsManager.GetActiveRecordingPath(existingTimer.Id) is null) { UpdateExistingTimerWithNewMetadata(existingTimer, timer); @@ -1770,60 +994,5 @@ namespace Jellyfin.LiveTv.EmbyTV return false; } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _recordingDeleteSemaphore.Dispose(); - - foreach (var pair in _activeRecordings.ToList()) - { - pair.Value.CancellationTokenSource.Cancel(); - } - - _disposed = true; - } - - public IEnumerable GetRecordingFolders() - { - var defaultFolder = RecordingPath; - var defaultName = "Recordings"; - - if (Directory.Exists(defaultFolder)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { defaultFolder }, - Name = defaultName - }; - } - - var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath; - if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { customPath }, - Name = "Recorded Movies", - CollectionType = CollectionTypeOptions.Movies - }; - } - - customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath; - if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { customPath }, - Name = "Recorded Shows", - CollectionType = CollectionTypeOptions.TvShows - }; - } - } } } diff --git a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs index dc15d53ffa..18ff6a949f 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.LiveTv.Timers; using MediaBrowser.Controller.LiveTv; using Microsoft.Extensions.Hosting; @@ -12,19 +11,26 @@ namespace Jellyfin.LiveTv.EmbyTV; /// public sealed class LiveTvHost : IHostedService { - private readonly EmbyTV _service; + private readonly IRecordingsManager _recordingsManager; + private readonly TimerManager _timerManager; /// /// Initializes a new instance of the class. /// - /// The available s. - public LiveTvHost(IEnumerable services) + /// The . + /// The . + public LiveTvHost(IRecordingsManager recordingsManager, TimerManager timerManager) { - _service = services.OfType().First(); + _recordingsManager = recordingsManager; + _timerManager = timerManager; } /// - public Task StartAsync(CancellationToken cancellationToken) => _service.Start(); + public Task StartAsync(CancellationToken cancellationToken) + { + _timerManager.RestartTimers(); + return _recordingsManager.CreateRecordingFolders(); + } /// public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index d02be31cfa..e247ecb44d 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -28,12 +28,14 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 394fbbaeab..056bb6e6d2 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -34,6 +34,7 @@ public class GuideManager : IGuideManager private readonly ILibraryManager _libraryManager; private readonly ILiveTvManager _liveTvManager; private readonly ITunerHostManager _tunerHostManager; + private readonly IRecordingsManager _recordingsManager; private readonly LiveTvDtoService _tvDtoService; /// @@ -46,6 +47,7 @@ public class GuideManager : IGuideManager /// The . /// The . /// The . + /// The . /// The . public GuideManager( ILogger logger, @@ -55,6 +57,7 @@ public class GuideManager : IGuideManager ILibraryManager libraryManager, ILiveTvManager liveTvManager, ITunerHostManager tunerHostManager, + IRecordingsManager recordingsManager, LiveTvDtoService tvDtoService) { _logger = logger; @@ -64,6 +67,7 @@ public class GuideManager : IGuideManager _libraryManager = libraryManager; _liveTvManager = liveTvManager; _tunerHostManager = tunerHostManager; + _recordingsManager = recordingsManager; _tvDtoService = tvDtoService; } @@ -85,7 +89,7 @@ public class GuideManager : IGuideManager { ArgumentNullException.ThrowIfNull(progress); - await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); + await _recordingsManager.CreateRecordingFolders().ConfigureAwait(false); await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 6b4ce6f7c1..f7b9604afd 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -43,6 +43,7 @@ namespace Jellyfin.LiveTv private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly IChannelManager _channelManager; + private readonly IRecordingsManager _recordingsManager; private readonly LiveTvDtoService _tvDtoService; private readonly ILiveTvService[] _services; @@ -55,6 +56,7 @@ namespace Jellyfin.LiveTv ILibraryManager libraryManager, ILocalizationManager localization, IChannelManager channelManager, + IRecordingsManager recordingsManager, LiveTvDtoService liveTvDtoService, IEnumerable services) { @@ -67,6 +69,7 @@ namespace Jellyfin.LiveTv _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; + _recordingsManager = recordingsManager; _services = services.ToArray(); var defaultService = _services.OfType().First(); @@ -88,11 +91,6 @@ namespace Jellyfin.LiveTv /// The services. public IReadOnlyList Services => _services; - public string GetEmbyTvActiveRecordingPath(string id) - { - return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); - } - private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs e) { var timerId = e.Argument; @@ -765,18 +763,13 @@ namespace Jellyfin.LiveTv return AddRecordingInfo(programTuples, CancellationToken.None); } - public ActiveRecordingInfo GetActiveRecordingInfo(string path) - { - return EmbyTV.EmbyTV.Current.GetActiveRecordingInfo(path); - } - public void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null) { - var service = EmbyTV.EmbyTV.Current; - var info = activeRecordingInfo.Timer; - var channel = string.IsNullOrWhiteSpace(info.ChannelId) ? null : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(service.Name, info.ChannelId)); + var channel = string.IsNullOrWhiteSpace(info.ChannelId) + ? null + : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(EmbyTV.EmbyTV.ServiceName, info.ChannelId)); dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null @@ -1461,7 +1454,7 @@ namespace Jellyfin.LiveTv private async Task GetRecordingFoldersAsync(User user, bool refreshChannels) { - var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders() + var folders = _recordingsManager.GetRecordingFolders() .SelectMany(i => i.Locations) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(i => _libraryManager.FindByPath(i, true)) diff --git a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs index ce9361089c..c6874e4dbc 100644 --- a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs +++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs @@ -24,13 +24,15 @@ namespace Jellyfin.LiveTv private const char StreamIdDelimiter = '_'; private readonly ILiveTvManager _liveTvManager; + private readonly IRecordingsManager _recordingsManager; private readonly ILogger _logger; private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerApplicationHost _appHost; - public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost) + public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IRecordingsManager recordingsManager, ILogger logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost) { _liveTvManager = liveTvManager; + _recordingsManager = recordingsManager; _logger = logger; _mediaSourceManager = mediaSourceManager; _appHost = appHost; @@ -40,7 +42,7 @@ namespace Jellyfin.LiveTv { if (item.SourceType == SourceType.LiveTV) { - var activeRecordingInfo = _liveTvManager.GetActiveRecordingInfo(item.Path); + var activeRecordingInfo = _recordingsManager.GetActiveRecordingInfo(item.Path); if (string.IsNullOrEmpty(item.Path) || activeRecordingInfo is not null) { diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs new file mode 100644 index 0000000000..4ac205492f --- /dev/null +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -0,0 +1,849 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AsyncKeyedLock; +using Jellyfin.Data.Enums; +using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.EmbyTV; +using Jellyfin.LiveTv.IO; +using Jellyfin.LiveTv.Timers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Recordings; + +/// +public sealed class RecordingsManager : IRecordingsManager, IDisposable +{ + private readonly ILogger _logger; + private readonly IServerConfigurationManager _config; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILibraryMonitor _libraryMonitor; + private readonly IProviderManager _providerManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IStreamHelper _streamHelper; + private readonly TimerManager _timerManager; + private readonly SeriesTimerManager _seriesTimerManager; + private readonly RecordingsMetadataManager _recordingsMetadataManager; + + private readonly ConcurrentDictionary _activeRecordings = new(StringComparer.OrdinalIgnoreCase); + private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(); + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + public RecordingsManager( + ILogger logger, + IServerConfigurationManager config, + IHttpClientFactory httpClientFactory, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor, + IProviderManager providerManager, + IMediaEncoder mediaEncoder, + IMediaSourceManager mediaSourceManager, + IStreamHelper streamHelper, + TimerManager timerManager, + SeriesTimerManager seriesTimerManager, + RecordingsMetadataManager recordingsMetadataManager) + { + _logger = logger; + _config = config; + _httpClientFactory = httpClientFactory; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + _providerManager = providerManager; + _mediaEncoder = mediaEncoder; + _mediaSourceManager = mediaSourceManager; + _streamHelper = streamHelper; + _timerManager = timerManager; + _seriesTimerManager = seriesTimerManager; + _recordingsMetadataManager = recordingsMetadataManager; + + _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; + } + + private string DefaultRecordingPath + { + get + { + var path = _config.GetLiveTvConfiguration().RecordingPath; + + return string.IsNullOrWhiteSpace(path) + ? Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv", "recordings") + : path; + } + } + + /// + public string? GetActiveRecordingPath(string id) + => _activeRecordings.GetValueOrDefault(id)?.Path; + + /// + public ActiveRecordingInfo? GetActiveRecordingInfo(string path) + { + if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) + { + return null; + } + + foreach (var (_, recordingInfo) in _activeRecordings) + { + if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) + && !recordingInfo.CancellationTokenSource.IsCancellationRequested) + { + return recordingInfo.Timer.Status == RecordingStatus.InProgress ? recordingInfo : null; + } + } + + return null; + } + + /// + public IEnumerable GetRecordingFolders() + { + if (Directory.Exists(DefaultRecordingPath)) + { + yield return new VirtualFolderInfo + { + Locations = [DefaultRecordingPath], + Name = "Recordings" + }; + } + + var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath; + if (!string.IsNullOrWhiteSpace(customPath) + && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(customPath)) + { + yield return new VirtualFolderInfo + { + Locations = [customPath], + Name = "Recorded Movies", + CollectionType = CollectionTypeOptions.Movies + }; + } + + customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath; + if (!string.IsNullOrWhiteSpace(customPath) + && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(customPath)) + { + yield return new VirtualFolderInfo + { + Locations = [customPath], + Name = "Recorded Shows", + CollectionType = CollectionTypeOptions.TvShows + }; + } + } + + /// + public async Task CreateRecordingFolders() + { + try + { + var recordingFolders = GetRecordingFolders().ToArray(); + var virtualFolders = _libraryManager.GetVirtualFolders(); + + var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); + + var pathsAdded = new List(); + + foreach (var recordingFolder in recordingFolders) + { + var pathsToCreate = recordingFolder.Locations + .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i))) + .ToList(); + + if (pathsToCreate.Count == 0) + { + continue; + } + + var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); + var libraryOptions = new LibraryOptions + { + PathInfos = mediaPathInfos + }; + + try + { + await _libraryManager + .AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating virtual folder"); + } + + pathsAdded.AddRange(pathsToCreate); + } + + var config = _config.GetLiveTvConfiguration(); + + var pathsToRemove = config.MediaLocationsCreated + .Except(recordingFolders.SelectMany(i => i.Locations)) + .ToList(); + + if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) + { + pathsAdded.InsertRange(0, config.MediaLocationsCreated); + config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + _config.SaveConfiguration("livetv", config); + } + + foreach (var path in pathsToRemove) + { + await RemovePathFromLibraryAsync(path).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating recording folders"); + } + } + + private async Task RemovePathFromLibraryAsync(string path) + { + _logger.LogDebug("Removing path from library: {0}", path); + + var requiresRefresh = false; + var virtualFolders = _libraryManager.GetVirtualFolders(); + + foreach (var virtualFolder in virtualFolders) + { + if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + if (virtualFolder.Locations.Length == 1) + { + try + { + await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing virtual folder"); + } + } + else + { + try + { + _libraryManager.RemoveMediaPath(virtualFolder.Name, path); + requiresRefresh = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing media path"); + } + } + } + + if (requiresRefresh) + { + await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + } + } + + /// + public void CancelRecording(string timerId, TimerInfo? timer) + { + if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) + { + activeRecordingInfo.Timer = timer; + activeRecordingInfo.CancellationTokenSource.Cancel(); + } + } + + /// + public async Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate) + { + ArgumentNullException.ThrowIfNull(recordingInfo); + ArgumentNullException.ThrowIfNull(channel); + + var timer = recordingInfo.Timer; + var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); + var recordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath); + + string? liveStreamId = null; + RecordingStatus recordingStatus; + try + { + var allMediaSources = await _mediaSourceManager + .GetPlaybackMediaSources(channel, null, true, false, CancellationToken.None).ConfigureAwait(false); + + var mediaStreamInfo = allMediaSources[0]; + IDirectStreamProvider? directStreamProvider = null; + if (mediaStreamInfo.RequiresOpening) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( + new LiveStreamRequest + { + ItemId = channel.Id, + OpenToken = mediaStreamInfo.OpenToken + }, + CancellationToken.None).ConfigureAwait(false); + + mediaStreamInfo = liveStreamResponse.Item1.MediaSource; + liveStreamId = mediaStreamInfo.LiveStreamId; + directStreamProvider = liveStreamResponse.Item2; + } + + using var recorder = GetRecorder(mediaStreamInfo); + + recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath); + recordingPath = EnsureFileUnique(recordingPath, timer.Id); + + _libraryMonitor.ReportFileSystemChangeBeginning(recordingPath); + + var duration = recordingEndDate - DateTime.UtcNow; + + _logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes); + _logger.LogInformation("Writing file to: {Path}", recordingPath); + + async void OnStarted() + { + recordingInfo.Path = recordingPath; + _activeRecordings.TryAdd(timer.Id, recordingInfo); + + timer.Status = RecordingStatus.InProgress; + _timerManager.AddOrUpdate(timer, false); + + await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(false); + await CreateRecordingFolders().ConfigureAwait(false); + + TriggerRefresh(recordingPath); + await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); + } + + await recorder.Record( + directStreamProvider, + mediaStreamInfo, + recordingPath, + duration, + OnStarted, + recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); + + recordingStatus = RecordingStatus.Completed; + _logger.LogInformation("Recording completed: {RecordPath}", recordingPath); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Recording stopped: {RecordPath}", recordingPath); + recordingStatus = RecordingStatus.Completed; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording to {RecordPath}", recordingPath); + recordingStatus = RecordingStatus.Error; + } + + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream"); + } + } + + DeleteFileIfEmpty(recordingPath); + TriggerRefresh(recordingPath); + _libraryMonitor.ReportFileSystemChangeComplete(recordingPath, false); + _activeRecordings.TryRemove(timer.Id, out _); + + if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) + { + const int RetryIntervalSeconds = 60; + _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); + + timer.Status = RecordingStatus.New; + timer.PrePaddingSeconds = 0; + timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); + timer.RetryCount++; + _timerManager.AddOrUpdate(timer); + } + else if (File.Exists(recordingPath)) + { + timer.RecordingPath = recordingPath; + timer.Status = RecordingStatus.Completed; + _timerManager.AddOrUpdate(timer, false); + PostProcessRecording(recordingPath); + } + else + { + _timerManager.Delete(timer); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _recordingDeleteSemaphore.Dispose(); + + foreach (var pair in _activeRecordings.ToList()) + { + pair.Value.CancellationTokenSource.Cancel(); + } + + _disposed = true; + } + + private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e) + { + if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) + { + await CreateRecordingFolders().ConfigureAwait(false); + } + } + + private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) + { + if (!timer.IsSeries || timer.SeriesProviderIds.Count == 0) + { + return null; + } + + var query = new RemoteSearchQuery + { + SearchInfo = new SeriesInfo + { + ProviderIds = timer.SeriesProviderIds, + Name = timer.Name, + MetadataCountryCode = _config.Configuration.MetadataCountryCode, + MetadataLanguage = _config.Configuration.PreferredMetadataLanguage + } + }; + + var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); + + return results.FirstOrDefault(); + } + + private string GetRecordingPath(TimerInfo timer, RemoteSearchResult? metadata, out string? seriesPath) + { + var recordingPath = DefaultRecordingPath; + var config = _config.GetLiveTvConfiguration(); + seriesPath = null; + + if (timer.IsProgramSeries) + { + var customRecordingPath = config.SeriesRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase); + recordingPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Series"); + } + + // trim trailing period from the folder name + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); + + if (metadata is not null && metadata.ProductionYear.HasValue) + { + folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // Can't use the year here in the folder name because it is the year of the episode, not the series. + recordingPath = Path.Combine(recordingPath, folderName); + + seriesPath = recordingPath; + + if (timer.SeasonNumber.HasValue) + { + folderName = string.Format( + CultureInfo.InvariantCulture, + "Season {0}", + timer.SeasonNumber.Value); + recordingPath = Path.Combine(recordingPath, folderName); + } + } + else if (timer.IsMovie) + { + var customRecordingPath = config.MovieRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase); + recordingPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Movies"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // trim trailing period from the folder name + folderName = folderName.TrimEnd('.').Trim(); + + recordingPath = Path.Combine(recordingPath, folderName); + } + else if (timer.IsKids) + { + if (config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Kids"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // trim trailing period from the folder name + folderName = folderName.TrimEnd('.').Trim(); + + recordingPath = Path.Combine(recordingPath, folderName); + } + else if (timer.IsSports) + { + if (config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Sports"); + } + + recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + else + { + if (config.EnableRecordingSubfolders) + { + recordingPath = Path.Combine(recordingPath, "Other"); + } + + recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + + var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; + + return Path.Combine(recordingPath, recordingFileName); + } + + private void DeleteFileIfEmpty(string path) + { + var file = _fileSystem.GetFileInfo(path); + + if (file.Exists && file.Length == 0) + { + try + { + _fileSystem.DeleteFile(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); + } + } + } + + private void TriggerRefresh(string path) + { + _logger.LogInformation("Triggering refresh on {Path}", path); + + var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); + if (item is null) + { + return; + } + + _logger.LogInformation("Refreshing recording parent {Path}", item.Path); + _providerManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + RefreshPaths = + [ + path, + Path.GetDirectoryName(path), + Path.GetDirectoryName(Path.GetDirectoryName(path)) + ] + }, + RefreshPriority.High); + } + + private BaseItem? GetAffectedBaseItem(string? path) + { + BaseItem? item = null; + var parentPath = Path.GetDirectoryName(path); + while (item is null && !string.IsNullOrEmpty(path)) + { + item = _libraryManager.FindByPath(path, null); + path = Path.GetDirectoryName(path); + } + + if (item is not null + && item.GetType() == typeof(Folder) + && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) + { + var parentItem = item.GetParent(); + if (parentItem is not null && parentItem is not AggregateFolder) + { + item = parentItem; + } + } + + return item; + } + + private async Task EnforceKeepUpTo(TimerInfo timer, string? seriesPath) + { + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) + || string.IsNullOrWhiteSpace(seriesPath)) + { + return; + } + + var seriesTimerId = timer.SeriesTimerId; + var seriesTimer = _seriesTimerManager.GetAll() + .FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); + + if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) + { + return; + } + + if (_disposed) + { + return; + } + + using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false)) + { + if (_disposed) + { + return; + } + + var timersToDelete = _timerManager.GetAll() + .Where(timerInfo => timerInfo.Status == RecordingStatus.Completed + && !string.IsNullOrWhiteSpace(timerInfo.RecordingPath) + && string.Equals(timerInfo.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase) + && File.Exists(timerInfo.RecordingPath)) + .OrderByDescending(i => i.EndDate) + .Skip(seriesTimer.KeepUpTo - 1) + .ToList(); + + DeleteLibraryItemsForTimers(timersToDelete); + + if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) + { + return; + } + + var episodesToDelete = librarySeries.GetItemList( + new InternalItemsQuery + { + OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)], + IsVirtualItem = false, + IsFolder = false, + Recursive = true, + DtoOptions = new DtoOptions(true) + }) + .Where(i => i.IsFileProtocol && File.Exists(i.Path)) + .Skip(seriesTimer.KeepUpTo - 1); + + foreach (var item in episodesToDelete) + { + try + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting item"); + } + } + } + } + + private void DeleteLibraryItemsForTimers(List timers) + { + foreach (var timer in timers) + { + if (_disposed) + { + return; + } + + try + { + DeleteLibraryItemForTimer(timer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting recording"); + } + } + } + + private void DeleteLibraryItemForTimer(TimerInfo timer) + { + var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); + if (libraryItem is not null) + { + _libraryManager.DeleteItem( + libraryItem, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); + } + else if (File.Exists(timer.RecordingPath)) + { + _fileSystem.DeleteFile(timer.RecordingPath); + } + + _timerManager.Delete(timer); + } + + private string EnsureFileUnique(string path, string timerId) + { + var parent = Path.GetDirectoryName(path)!; + var name = Path.GetFileNameWithoutExtension(path); + var extension = Path.GetExtension(path); + + var index = 1; + while (File.Exists(path) || _activeRecordings.Any(i + => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) + && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase))) + { + name += " - " + index.ToString(CultureInfo.InvariantCulture); + + path = Path.ChangeExtension(Path.Combine(parent, name), extension); + index++; + } + + return path; + } + + private IRecorder GetRecorder(MediaSourceInfo mediaSource) + { + if (mediaSource.RequiresLooping + || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) + || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) + { + return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); + } + + return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); + } + + private void PostProcessRecording(string path) + { + var options = _config.GetLiveTvConfiguration(); + if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) + { + return; + } + + try + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + Arguments = options.RecordingPostProcessorArguments + .Replace("{path}", path, StringComparison.OrdinalIgnoreCase), + CreateNoWindow = true, + ErrorDialog = false, + FileName = options.RecordingPostProcessor, + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false + }, + EnableRaisingEvents = true + }; + + _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); + + process.Exited += OnProcessExited; + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running recording post processor"); + } + } + + private void OnProcessExited(object? sender, EventArgs e) + { + if (sender is Process process) + { + using (process) + { + _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); + } + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs index 33a9aca312..d5f6873a29 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs @@ -26,7 +26,7 @@ public class AudioResolverTests public AudioResolverTests() { // prep BaseItem and Video for calls made that expect managers - Video.LiveTvManager = Mock.Of(); + Video.RecordingsManager = Mock.Of(); var applicationPaths = new Mock().Object; var serverConfig = new Mock(); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 2b38675121..58b67ae553 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -37,7 +37,7 @@ public class MediaInfoResolverTests public MediaInfoResolverTests() { // prep BaseItem and Video for calls made that expect managers - Video.LiveTvManager = Mock.Of(); + Video.RecordingsManager = Mock.Of(); var applicationPaths = new Mock().Object; var serverConfig = new Mock(); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs index 0c1c269a4c..8077bd791c 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs @@ -26,7 +26,7 @@ public class SubtitleResolverTests public SubtitleResolverTests() { // prep BaseItem and Video for calls made that expect managers - Video.LiveTvManager = Mock.Of(); + Video.RecordingsManager = Mock.Of(); var applicationPaths = new Mock().Object; var serverConfig = new Mock(); From 170b8b2550a6ebb08453fe96d6c2223eaa1aa0ff Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 15 Feb 2024 10:47:59 -0500 Subject: [PATCH 121/136] 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 4ac205492f..20f89ec8f3 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); - } - } - } } 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: [PATCH 122/136] 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 f8012e90e0..275dc6f3ec 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 From d96fec2330e8df69c5b765fbd712cf8347a593a9 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 15 Feb 2024 12:52:24 -0500 Subject: [PATCH 123/136] Move RecordingHelper to recordings folder --- src/Jellyfin.LiveTv/{EmbyTV => Recordings}/RecordingHelper.cs | 4 +--- src/Jellyfin.LiveTv/Timers/TimerManager.cs | 2 +- tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) rename src/Jellyfin.LiveTv/{EmbyTV => Recordings}/RecordingHelper.cs (97%) diff --git a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs similarity index 97% rename from src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs rename to src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs index 6bda231b24..1c8e2960be 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs @@ -1,11 +1,9 @@ -#pragma warning disable CS1591 - using System; using System.Globalization; using System.Text; using MediaBrowser.Controller.LiveTv; -namespace Jellyfin.LiveTv.EmbyTV +namespace Jellyfin.LiveTv.Recordings { internal static class RecordingHelper { diff --git a/src/Jellyfin.LiveTv/Timers/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs index 6bcbd3324f..2e5003a537 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 b4960dc0b3..6a33a6699a 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; From 31f285480ae3b4573d2ddc18b50a8ed8b4160a41 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 15 Feb 2024 12:53:55 -0500 Subject: [PATCH 124/136] Move RecordingNotifier to recordings folder --- Jellyfin.Server/Startup.cs | 2 +- src/Jellyfin.LiveTv/{ => Recordings}/RecordingNotifier.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/Jellyfin.LiveTv/{ => Recordings}/RecordingNotifier.cs (99%) diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 558ad5b7bd..51b34fd158 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/Recordings/RecordingNotifier.cs similarity index 99% rename from src/Jellyfin.LiveTv/RecordingNotifier.cs rename to src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs index 226d525e71..e63afa6260 100644 --- a/src/Jellyfin.LiveTv/RecordingNotifier.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs @@ -11,7 +11,7 @@ using MediaBrowser.Model.Session; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Jellyfin.LiveTv +namespace Jellyfin.LiveTv.Recordings { /// /// responsible for notifying users when a LiveTV recording is completed. From 3beb10747f229324fc8ad045347b6d1f6372dc31 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 21 Feb 2024 11:09:38 -0500 Subject: [PATCH 125/136] Move GetNfoConfiguration to LiveTvConfigurationExtensions --- .../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 67d0e5295b..f7888496f4 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 e8570f0e0d..0000000000 --- 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"); - } -} From fa6d859a5146013c54a4677a50f2fccbcc6afd02 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 21 Feb 2024 11:10:57 -0500 Subject: [PATCH 126/136] Rename LiveTvHost to RecordingsHost and move to recordings folder --- Jellyfin.Server/Startup.cs | 2 +- .../LiveTvHost.cs => Recordings/RecordingsHost.cs} | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename src/Jellyfin.LiveTv/{EmbyTV/LiveTvHost.cs => Recordings/RecordingsHost.cs} (73%) diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 51b34fd158..6728cd0b40 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/Recordings/RecordingsHost.cs similarity index 73% rename from src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs rename to src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs index 18ff6a949f..f4daa09756 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs @@ -4,22 +4,22 @@ using Jellyfin.LiveTv.Timers; using MediaBrowser.Controller.LiveTv; using Microsoft.Extensions.Hosting; -namespace Jellyfin.LiveTv.EmbyTV; +namespace Jellyfin.LiveTv.Recordings; /// -/// responsible for initializing Live TV. +/// responsible for Live TV recordings. /// -public sealed class LiveTvHost : IHostedService +public sealed class RecordingsHost : IHostedService { private readonly IRecordingsManager _recordingsManager; private readonly TimerManager _timerManager; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The . /// The . - public LiveTvHost(IRecordingsManager recordingsManager, TimerManager timerManager) + public RecordingsHost(IRecordingsManager recordingsManager, TimerManager timerManager) { _recordingsManager = recordingsManager; _timerManager = timerManager; From cac7ff84ca407d7f452f1ea988f472118012f6da Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Wed, 21 Feb 2024 11:12:49 -0500 Subject: [PATCH 127/136] Rename EmbyTV to DefaultLiveTvService --- Jellyfin.Server/Startup.cs | 1 - .../{EmbyTV/EmbyTV.cs => DefaultLiveTvService.cs} | 10 +++++----- .../Extensions/LiveTvServiceCollectionExtensions.cs | 2 +- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 2 +- src/Jellyfin.LiveTv/LiveTvManager.cs | 10 +++++----- src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs | 1 - .../Recordings/RecordingsMetadataManager.cs | 1 - 7 files changed, 12 insertions(+), 15 deletions(-) rename src/Jellyfin.LiveTv/{EmbyTV/EmbyTV.cs => DefaultLiveTvService.cs} (99%) diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 6728cd0b40..e9fb3e4c27 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/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs similarity index 99% rename from src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs rename to src/Jellyfin.LiveTv/DefaultLiveTvService.cs index 06a0ea4e9d..318cc7acd0 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs @@ -24,13 +24,13 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.LiveTv; using Microsoft.Extensions.Logging; -namespace Jellyfin.LiveTv.EmbyTV +namespace Jellyfin.LiveTv { - public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds + public sealed class DefaultLiveTvService : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds { public const string ServiceName = "Emby"; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IServerConfigurationManager _config; private readonly ITunerHostManager _tunerHostManager; private readonly IListingsManager _listingsManager; @@ -40,8 +40,8 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly TimerManager _timerManager; private readonly SeriesTimerManager _seriesTimerManager; - public EmbyTV( - ILogger logger, + public DefaultLiveTvService( + ILogger logger, IServerConfigurationManager config, ITunerHostManager tunerHostManager, IListingsManager listingsManager, diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index e247ecb44d..73729c9505 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 056bb6e6d2..39f174cc2b 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 f7b9604afd..0af40a0597 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 20f89ec8f3..92605a1eb9 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 0a71a4d46e..b2b82332df 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; From 3b341c06db66ae675b37102e6c5d4009def1b48d Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 22 Feb 2024 09:43:55 -0500 Subject: [PATCH 128/136] 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 1c8e2960be..2b75640451 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 2e5003a537..da5deea36a 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) From b5a3c71b3aba0d8a1e1e65f7d07e1caae43856e2 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 22 Feb 2024 10:28:02 -0500 Subject: [PATCH 129/136] Move media source code from LiveTvManager to LiveTvMediaSourceProvider --- .../LiveTv/ILiveTvManager.cs | 19 -- src/Jellyfin.LiveTv/LiveTvManager.cs | 189 --------------- .../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 ed08cdc476..c0e46ba245 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -10,7 +10,6 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Events; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; @@ -105,16 +104,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task{QueryResult{SeriesTimerInfoDto}}. Task> GetSeriesTimers(SeriesTimerQuery query, CancellationToken cancellationToken); - /// - /// Gets the channel stream. - /// - /// The identifier. - /// The media source identifier. - /// The current live streams. - /// The cancellation token. - /// Task{StreamResponseInfo}. - Task> GetChannelStream(string id, string mediaSourceId, List currentLiveStreams, CancellationToken cancellationToken); - /// /// Gets the program. /// @@ -220,14 +209,6 @@ namespace MediaBrowser.Controller.LiveTv /// Internal channels. QueryResult GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken); - /// - /// Gets the channel media sources. - /// - /// Item to search for. - /// CancellationToken to use for operation. - /// Channel media sources wrapped in a task. - Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken); - /// /// Adds the information to program dto. /// diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 0af40a0597..c19d8195cd 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -12,7 +12,6 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.LiveTv.Configuration; -using Jellyfin.LiveTv.IO; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -152,73 +151,6 @@ namespace Jellyfin.LiveTv return _libraryManager.GetItemsResult(internalQuery); } - public async Task> GetChannelStream(string id, string mediaSourceId, List currentLiveStreams, CancellationToken cancellationToken) - { - if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - { - mediaSourceId = null; - } - - var channel = (LiveTvChannel)_libraryManager.GetItemById(id); - - bool isVideo = channel.ChannelType == ChannelType.TV; - var service = GetService(channel); - _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); - - MediaSourceInfo info; -#pragma warning disable CA1859 // TODO: Analyzer bug? - ILiveStream liveStream; -#pragma warning restore CA1859 - if (service is ISupportsDirectStreamProvider supportsManagedStream) - { - liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); - info = liveStream.MediaSource; - } - else - { - info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); - var openedId = info.Id; - Func closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None); - - liveStream = new ExclusiveLiveStream(info, closeFn); - - var startTime = DateTime.UtcNow; - await liveStream.Open(cancellationToken).ConfigureAwait(false); - var endTime = DateTime.UtcNow; - _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); - } - - info.RequiresClosing = true; - - var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; - - info.LiveStreamId = idPrefix + info.Id; - - Normalize(info, service, isVideo); - - return new Tuple(info, liveStream); - } - - public async Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken) - { - var baseItem = (LiveTvChannel)item; - var service = GetService(baseItem); - - var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); - - if (sources.Count == 0) - { - throw new NotImplementedException(); - } - - foreach (var source in sources) - { - Normalize(source, service, baseItem.ChannelType == ChannelType.TV); - } - - return sources; - } - private ILiveTvService GetService(LiveTvChannel item) { var name = item.ServiceName; @@ -240,127 +172,6 @@ namespace Jellyfin.LiveTv "No service with the name '{0}' can be found.", name)); - private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) - { - // Not all of the plugins are setting this - mediaSource.IsInfiniteStream = true; - - if (mediaSource.MediaStreams.Count == 0) - { - if (isVideo) - { - mediaSource.MediaStreams = new MediaStream[] - { - new MediaStream - { - Type = MediaStreamType.Video, - // Set the index to -1 because we don't know the exact index of the video stream within the container - Index = -1, - - // Set to true if unknown to enable deinterlacing - IsInterlaced = true - }, - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1 - } - }; - } - else - { - mediaSource.MediaStreams = new MediaStream[] - { - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1 - } - }; - } - } - - // Clean some bad data coming from providers - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.BitRate.HasValue && stream.BitRate <= 0) - { - stream.BitRate = null; - } - - if (stream.Channels.HasValue && stream.Channels <= 0) - { - stream.Channels = null; - } - - if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0) - { - stream.AverageFrameRate = null; - } - - if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0) - { - stream.RealFrameRate = null; - } - - if (stream.Width.HasValue && stream.Width <= 0) - { - stream.Width = null; - } - - if (stream.Height.HasValue && stream.Height <= 0) - { - stream.Height = null; - } - - if (stream.SampleRate.HasValue && stream.SampleRate <= 0) - { - stream.SampleRate = null; - } - - if (stream.Level.HasValue && stream.Level <= 0) - { - stream.Level = null; - } - } - - var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList(); - - // If there are duplicate stream indexes, set them all to unknown - if (indexes.Count != mediaSource.MediaStreams.Count) - { - foreach (var stream in mediaSource.MediaStreams) - { - stream.Index = -1; - } - } - - // Set the total bitrate if not already supplied - mediaSource.InferTotalBitrate(); - - if (service is not DefaultLiveTvService) - { - // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says - // mediaSource.SupportsDirectPlay = false; - // mediaSource.SupportsDirectStream = false; - mediaSource.SupportsTranscoding = true; - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) - { - stream.NalLengthSize = "0"; - } - - if (stream.Type == MediaStreamType.Video) - { - stream.IsInterlaced = true; - } - } - } - } - public async Task GetProgram(string id, CancellationToken cancellationToken, User user = null) { var program = _libraryManager.GetItemById(id); diff --git a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs index c6874e4dbc..40ac5ce0fd 100644 --- a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs +++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs @@ -8,11 +8,15 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.LiveTv.IO; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; @@ -23,19 +27,27 @@ namespace Jellyfin.LiveTv // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. private const char StreamIdDelimiter = '_'; - private readonly ILiveTvManager _liveTvManager; - private readonly IRecordingsManager _recordingsManager; private readonly ILogger _logger; - private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerApplicationHost _appHost; + private readonly IRecordingsManager _recordingsManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly ILibraryManager _libraryManager; + private readonly ILiveTvService[] _services; - public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IRecordingsManager recordingsManager, ILogger logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost) + public LiveTvMediaSourceProvider( + ILogger logger, + IServerApplicationHost appHost, + IRecordingsManager recordingsManager, + IMediaSourceManager mediaSourceManager, + ILibraryManager libraryManager, + IEnumerable services) { - _liveTvManager = liveTvManager; - _recordingsManager = recordingsManager; _logger = logger; - _mediaSourceManager = mediaSourceManager; _appHost = appHost; + _recordingsManager = recordingsManager; + _mediaSourceManager = mediaSourceManager; + _libraryManager = libraryManager; + _services = services.ToArray(); } public Task> GetMediaSources(BaseItem item, CancellationToken cancellationToken) @@ -68,7 +80,7 @@ namespace Jellyfin.LiveTv } else { - sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken) + sources = await GetChannelMediaSources(item, cancellationToken) .ConfigureAwait(false); } } @@ -121,10 +133,200 @@ namespace Jellyfin.LiveTv var keys = openToken.Split(StreamIdDelimiter, 3); var mediaSourceId = keys.Length >= 3 ? keys[2] : null; - var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + var info = await GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); var liveStream = info.Item2; return liveStream; } + + private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) + { + // Not all of the plugins are setting this + mediaSource.IsInfiniteStream = true; + + if (mediaSource.MediaStreams.Count == 0) + { + if (isVideo) + { + mediaSource.MediaStreams = new[] + { + new MediaStream + { + Type = MediaStreamType.Video, + // Set the index to -1 because we don't know the exact index of the video stream within the container + Index = -1, + // Set to true if unknown to enable deinterlacing + IsInterlaced = true + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }; + } + else + { + mediaSource.MediaStreams = new[] + { + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }; + } + } + + // Clean some bad data coming from providers + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.BitRate is <= 0) + { + stream.BitRate = null; + } + + if (stream.Channels is <= 0) + { + stream.Channels = null; + } + + if (stream.AverageFrameRate is <= 0) + { + stream.AverageFrameRate = null; + } + + if (stream.RealFrameRate is <= 0) + { + stream.RealFrameRate = null; + } + + if (stream.Width is <= 0) + { + stream.Width = null; + } + + if (stream.Height is <= 0) + { + stream.Height = null; + } + + if (stream.SampleRate is <= 0) + { + stream.SampleRate = null; + } + + if (stream.Level is <= 0) + { + stream.Level = null; + } + } + + var indexCount = mediaSource.MediaStreams.Select(i => i.Index).Distinct().Count(); + + // If there are duplicate stream indexes, set them all to unknown + if (indexCount != mediaSource.MediaStreams.Count) + { + foreach (var stream in mediaSource.MediaStreams) + { + stream.Index = -1; + } + } + + // Set the total bitrate if not already supplied + mediaSource.InferTotalBitrate(); + + if (service is not DefaultLiveTvService) + { + mediaSource.SupportsTranscoding = true; + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) + { + stream.NalLengthSize = "0"; + } + + if (stream.Type == MediaStreamType.Video) + { + stream.IsInterlaced = true; + } + } + } + } + + private async Task> GetChannelStream( + string id, + string mediaSourceId, + List currentLiveStreams, + CancellationToken cancellationToken) + { + if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + { + mediaSourceId = null; + } + + var channel = (LiveTvChannel)_libraryManager.GetItemById(id); + + bool isVideo = channel.ChannelType == ChannelType.TV; + var service = GetService(channel.ServiceName); + _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); + + MediaSourceInfo info; +#pragma warning disable CA1859 // TODO: Analyzer bug? + ILiveStream liveStream; +#pragma warning restore CA1859 + if (service is ISupportsDirectStreamProvider supportsManagedStream) + { + liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + info = liveStream.MediaSource; + } + else + { + info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); + var openedId = info.Id; + Func closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None); + + liveStream = new ExclusiveLiveStream(info, closeFn); + + var startTime = DateTime.UtcNow; + await liveStream.Open(cancellationToken).ConfigureAwait(false); + var endTime = DateTime.UtcNow; + _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); + } + + info.RequiresClosing = true; + + var idPrefix = service.GetType().FullName!.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; + + info.LiveStreamId = idPrefix + info.Id; + + Normalize(info, service, isVideo); + + return new Tuple(info, liveStream); + } + + private async Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken) + { + var baseItem = (LiveTvChannel)item; + var service = GetService(baseItem.ServiceName); + + var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); + if (sources.Count == 0) + { + throw new NotImplementedException(); + } + + foreach (var source in sources) + { + Normalize(source, service, baseItem.ChannelType == ChannelType.TV); + } + + return sources; + } + + private ILiveTvService GetService(string name) + => _services.First(service => string.Equals(service.Name, name, StringComparison.OrdinalIgnoreCase)); } } From a1bb23e98f8da896fc0530933c5fc74d6d4a50c3 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Fri, 23 Feb 2024 09:02:11 -0700 Subject: [PATCH 130/136] 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 e357588d1d..984dc77896 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 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: [PATCH 131/136] 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 275dc6f3ec..6e2da9737f 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 4b5db14aef..8ee6b3028b 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/" From 36f298e4173b4f08edfdf9a3a2f09e887846e935 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 25 Feb 2024 17:24:54 +0100 Subject: [PATCH 132/136] 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 146b306435..8bace15c65 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 { From 59f50ae8b2555b8caa19e743c3ba612e999f75bf Mon Sep 17 00:00:00 2001 From: felix920506 Date: Sun, 25 Feb 2024 21:25:37 -0500 Subject: [PATCH 133/136] 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 b51500f870..0000000000 --- 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] From 0bc41c015f4ec907de75fe215589b7e30a819b54 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Mon, 26 Feb 2024 05:09:40 -0700 Subject: [PATCH 134/136] Store lyrics in the database as media streams (#9951) --- Emby.Naming/Common/NamingOptions.cs | 12 + .../ExternalFiles/ExternalPathParser.cs | 3 +- Emby.Server.Implementations/Dto/DtoService.cs | 13 +- .../Library/LibraryManager.cs | 13 + .../UserPermissionHandler.cs | 27 +- Jellyfin.Api/Controllers/LyricsController.cs | 267 +++++++++++ .../Controllers/SubtitleController.cs | 38 +- .../Controllers/UserLibraryController.cs | 45 +- Jellyfin.Data/Entities/User.cs | 1 + Jellyfin.Data/Enums/PermissionKind.cs | 7 +- .../Library/LyricDownloadFailureLogger.cs | 101 ++++ .../EventingServiceCollectionExtensions.cs | 2 + .../Users/UserManager.cs | 1 + .../ApiServiceCollectionExtensions.cs | 2 +- MediaBrowser.Common/Api/Policies.cs | 5 + .../Entities/Audio/Audio.cs | 12 + .../Library/ILibraryManager.cs | 9 + .../Lyrics/ILyricManager.cs | 100 +++- .../Lyrics/ILyricParser.cs | 4 +- .../Lyrics/ILyricProvider.cs | 34 ++ .../Lyrics/LyricDownloadFailureEventArgs.cs | 26 ++ .../Configuration/LibraryOptions.cs | 5 + .../Configuration/MetadataPluginType.cs | 3 +- MediaBrowser.Model/Dlna/DlnaProfileType.cs | 3 +- .../Entities/MediaStreamType.cs | 7 +- .../Lyrics/LyricDto.cs | 7 +- .../Lyrics/LyricFile.cs | 2 +- .../Lyrics/LyricLine.cs | 2 +- .../Lyrics/LyricMetadata.cs | 7 +- MediaBrowser.Model/Lyrics/LyricResponse.cs | 19 + .../Lyrics/LyricSearchRequest.cs | 59 +++ .../Lyrics/RemoteLyricInfoDto.cs | 22 + MediaBrowser.Model/Lyrics/UploadLyricDto.cs | 16 + .../Providers/LyricProviderInfo.cs | 17 + .../Providers/RemoteLyricInfo.cs | 29 ++ MediaBrowser.Model/Users/UserPolicy.cs | 6 + .../Lyric/DefaultLyricProvider.cs | 69 --- .../Lyric/ILyricProvider.cs | 36 -- .../Lyric/LrcLyricParser.cs | 15 +- MediaBrowser.Providers/Lyric/LyricManager.cs | 432 +++++++++++++++++- .../Lyric/TxtLyricParser.cs | 11 +- .../Manager/ProviderManager.cs | 18 +- .../MediaInfo/AudioFileProber.cs | 34 +- .../MediaInfo/LyricResolver.cs | 39 ++ .../MediaInfo/MediaInfoResolver.cs | 97 +++- .../MediaInfo/ProbeProvider.cs | 50 +- .../Subtitles/SubtitleManager.cs | 2 +- src/Jellyfin.Extensions/StringExtensions.cs | 10 + .../Manager/ProviderManagerTests.cs | 4 +- 49 files changed, 1481 insertions(+), 262 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 rename MediaBrowser.Controller/Lyrics/LyricResponse.cs => MediaBrowser.Model/Lyrics/LyricDto.cs (66%) rename {MediaBrowser.Controller => MediaBrowser.Model}/Lyrics/LyricFile.cs (94%) rename {MediaBrowser.Controller => MediaBrowser.Model}/Lyrics/LyricLine.cs (93%) rename {MediaBrowser.Controller => MediaBrowser.Model}/Lyrics/LyricMetadata.cs (87%) 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 b63c8f10e5..4bd226d95e 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 4080ba10d3..9d54533c24 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 d372277e0a..7812687ea3 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 7998ce34a7..13a3810600 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 e72bec46fd..764c0a435f 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,16 +26,28 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy /// protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement) { - var user = _userManager.GetUserById(context.User.GetUserId()); - if (user is null) - { - throw new ResourceNotFoundException(); - } - - if (user.HasPermission(requirement.RequiredPermission)) + // Api keys have global permissions, so just succeed the requirement. + if (context.User.GetIsApiKey()) { context.Succeed(requirement); } + else + { + 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 0000000000..4fccf2cb42 --- /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 0000000000..0831a4c4e6 --- /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 0000000000..1b1f36020c --- /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.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 1c071067df..42148a2761 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 4c5e952664..ef303726d1 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 c1a663bf17..1bb885c447 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 83751a6a7d..0964bb7699 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.Controller/Lyrics/LyricResponse.cs b/MediaBrowser.Model/Lyrics/LyricDto.cs similarity index 66% rename from MediaBrowser.Controller/Lyrics/LyricResponse.cs rename to MediaBrowser.Model/Lyrics/LyricDto.cs index 0d52b5ec50..7a9bffc99c 100644 --- a/MediaBrowser.Controller/Lyrics/LyricResponse.cs +++ b/MediaBrowser.Model/Lyrics/LyricDto.cs @@ -1,12 +1,11 @@ -using System; using System.Collections.Generic; -namespace MediaBrowser.Controller.Lyrics; +namespace MediaBrowser.Model.Lyrics; /// /// LyricResponse model. /// -public class LyricResponse +public class LyricDto { /// /// Gets or sets Metadata for the lyrics. @@ -16,5 +15,5 @@ public class LyricResponse /// /// Gets or sets a collection of individual lyric lines. /// - public IReadOnlyList Lyrics { get; set; } = Array.Empty(); + public IReadOnlyList Lyrics { get; set; } = []; } diff --git a/MediaBrowser.Controller/Lyrics/LyricFile.cs b/MediaBrowser.Model/Lyrics/LyricFile.cs similarity index 94% rename from MediaBrowser.Controller/Lyrics/LyricFile.cs rename to MediaBrowser.Model/Lyrics/LyricFile.cs index ede89403c6..3912b037e0 100644 --- a/MediaBrowser.Controller/Lyrics/LyricFile.cs +++ b/MediaBrowser.Model/Lyrics/LyricFile.cs @@ -1,4 +1,4 @@ -namespace MediaBrowser.Providers.Lyric; +namespace MediaBrowser.Model.Lyrics; /// /// The information for a raw lyrics file before parsing. diff --git a/MediaBrowser.Controller/Lyrics/LyricLine.cs b/MediaBrowser.Model/Lyrics/LyricLine.cs similarity index 93% rename from MediaBrowser.Controller/Lyrics/LyricLine.cs rename to MediaBrowser.Model/Lyrics/LyricLine.cs index c406f92fcc..64d1f64c2c 100644 --- a/MediaBrowser.Controller/Lyrics/LyricLine.cs +++ b/MediaBrowser.Model/Lyrics/LyricLine.cs @@ -1,4 +1,4 @@ -namespace MediaBrowser.Controller.Lyrics; +namespace MediaBrowser.Model.Lyrics; /// /// Lyric model. diff --git a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs b/MediaBrowser.Model/Lyrics/LyricMetadata.cs similarity index 87% rename from MediaBrowser.Controller/Lyrics/LyricMetadata.cs rename to MediaBrowser.Model/Lyrics/LyricMetadata.cs index c4f0334892..4f819d6c92 100644 --- a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs +++ b/MediaBrowser.Model/Lyrics/LyricMetadata.cs @@ -1,4 +1,4 @@ -namespace MediaBrowser.Controller.Lyrics; +namespace MediaBrowser.Model.Lyrics; /// /// LyricMetadata model. @@ -49,4 +49,9 @@ public class LyricMetadata /// 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 0000000000..b04adeb7b4 --- /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 0000000000..48c442a55e --- /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 0000000000..dda56d1989 --- /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 0000000000..0ea8a4c638 --- /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 0000000000..ea9c94185d --- /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 0000000000..9fb340a58d --- /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 219ed5d5f7..951e057632 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 ab09f278aa..0000000000 --- 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 27ceba72bf..0000000000 --- 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 a10ff198b4..67b26e4570 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 6da8119275..60734b89ae 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) { - _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray(); - _lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray(); + _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 async Task GetLyrics(BaseItem item) + public event EventHandler? LyricDownloadFailure; + + /// + public Task> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken) { - foreach (ILyricProvider provider in _lyricProviders) + ArgumentNullException.ThrowIfNull(audio); + + var request = new LyricSearchRequest { - var lyrics = await provider.GetLyrics(item).ConfigureAwait(false); - if (lyrics is null) + 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 => { - continue; + 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; + } } - foreach (ILyricParser parser in _lyricParsers) + 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) { - var result = parser.ParseLyrics(lyrics); - if (result is not 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) + { + 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 Task DeleteLyricsAsync(Audio audio) + { + ArgumentNullException.ThrowIfNull(audio); + var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery + { + ItemId = audio.Id, + Type = MediaStreamType.Lyric + }); + + foreach (var stream in streams) + { + var path = stream.Path; + _libraryMonitor.ReportFileSystemChangeBeginning(path); + + try + { + _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(); + } + + /// + 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 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) { - foreach (ILyricProvider provider in _lyricProviders) + var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringComparison.Ordinal)); + if (provider is null) { - if (item is null) - { - continue; - } + _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty)); + } - if (provider.HasLyrics(item)) + 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 true; + return parsedLyrics; } } - return false; + 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) + { + var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia; + + var memoryStream = new MemoryStream(); + await using (memoryStream.ConfigureAwait(false)) + { + var stream = lyricResponse.Stream; + + await using (stream.ConfigureAwait(false)) + { + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Seek(0, SeekOrigin.Begin); + } + + var savePaths = new List(); + var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + lyricResponse.Format.ReplaceLineEndings(string.Empty).ToLowerInvariant(); + + if (saveInMediaFolder) + { + 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."); + } + } + } + + 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 706f13dbcd..a8188da287 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 2e9547bf31..81a2990159 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 f718325df5..fb86e254f3 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 0000000000..52af5ea08d --- /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 f846aa5dec..fbec4e9634 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 114a929753..8bb874f0d3 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 87fd2a3cda..f68b3cee62 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 fd8f7e59a4..9d8afc23c7 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 1e0851993b..478db69412 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; } From 2e0e1ecc99fbcbdf7ca272413ca30c4a63de97d7 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Mon, 26 Feb 2024 15:57:59 -0700 Subject: [PATCH 135/136] Rename route parameters that are id to be more descriptive --- .../Controllers/InstantMixController.cs | 40 +++++++++---------- .../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 e7ff1f9868..3cf4852995 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 d6ec40a7e1..cc2a630e1d 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)); } 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: [PATCH 136/136] 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 97f1a33e76..c56349941f 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