diff options
Diffstat (limited to 'MediaBrowser.Controller')
20 files changed, 686 insertions, 211 deletions
diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs index 9fae43033..7d5207c34 100644 --- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs +++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 namespace MediaBrowser.Controller.Channels @@ -11,6 +9,6 @@ namespace MediaBrowser.Controller.Channels /// </summary> /// <param name="userId">The user identifier.</param> /// <returns>System.String.</returns> - string GetCacheKey(string userId); + string? GetCacheKey(string? userId); } } diff --git a/MediaBrowser.Controller/Channels/ISearchableChannel.cs b/MediaBrowser.Controller/Channels/ISearchableChannel.cs deleted file mode 100644 index b87943a6e..000000000 --- a/MediaBrowser.Controller/Channels/ISearchableChannel.cs +++ /dev/null @@ -1,21 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace MediaBrowser.Controller.Channels -{ - public interface ISearchableChannel - { - /// <summary> - /// Searches the specified search term. - /// </summary> - /// <param name="searchInfo">The search information.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{IEnumerable{ChannelItemInfo}}.</returns> - Task<IEnumerable<ChannelItemInfo>> Search(ChannelSearchInfo searchInfo, CancellationToken cancellationToken); - } -} diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs index 8ad93387e..8ecc68bab 100644 --- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs +++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs @@ -1,6 +1,4 @@ -#nullable disable - -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System.Collections.Generic; using System.Threading; diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index ddcc994a0..5f9840b1b 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -62,7 +62,9 @@ namespace MediaBrowser.Controller.Entities ".edl", ".bif", ".smi", - ".ttml" + ".ttml", + ".lrc", + ".elrc" }; /// <summary> @@ -831,7 +833,7 @@ namespace MediaBrowser.Controller.Entities return CanDelete() && IsAuthorizedToDelete(user, allCollectionFolders); } - public bool CanDelete(User user) + public virtual bool CanDelete(User user) { var allCollectionFolders = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); @@ -962,7 +964,13 @@ namespace MediaBrowser.Controller.Entities AppendChunk(builder, isDigitChunk, name.Slice(chunkStart)); // logger.LogDebug("ModifySortChunks Start: {0} End: {1}", name, builder.ToString()); - return builder.ToString().RemoveDiacritics(); + var result = builder.ToString().RemoveDiacritics(); + if (!result.All(char.IsAscii)) + { + result = result.Transliterated(); + } + + return result; } public BaseItem GetParent() @@ -1578,18 +1586,24 @@ namespace MediaBrowser.Controller.Entities list.AddRange(parent.Tags); } + foreach (var folder in LibraryManager.GetCollectionFolders(this)) + { + list.AddRange(folder.Tags); + } + return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } private bool IsVisibleViaTags(User user) { - if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => Tags.Contains(i, StringComparison.OrdinalIgnoreCase))) + var allTags = GetInheritedTags(); + if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) { return false; } var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags); - if (allowedTagsPreference.Any() && !allowedTagsPreference.Any(i => Tags.Contains(i, StringComparison.OrdinalIgnoreCase))) + if (allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) { return false; } diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index 992bb19bb..676a47c88 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Controller.IO; @@ -95,6 +96,16 @@ namespace MediaBrowser.Controller.Entities return GetLibraryOptions(Path); } + public override bool IsVisible(User user) + { + if (GetLibraryOptions().Enabled) + { + return base.IsVisible(user); + } + + return false; + } + private static LibraryOptions LoadLibraryOptions(string path) { try diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 1f13c833b..8bfcf5dee 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -331,8 +331,25 @@ namespace MediaBrowser.Controller.Entities } } + private static bool IsLibraryFolderAccessible(IDirectoryService directoryService, BaseItem item) + { + // For top parents i.e. Library folders, skip the validation if it's empty or inaccessible + if (item.IsTopParent && !directoryService.IsAccessible(item.ContainingFolderPath)) + { + Logger.LogWarning("Library folder {LibraryFolderPath} is inaccessible or empty, skipping", item.ContainingFolderPath); + return false; + } + + return true; + } + private async Task ValidateChildrenInternal2(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { + if (!IsLibraryFolderAccessible(directoryService, this)) + { + return; + } + cancellationToken.ThrowIfCancellationRequested(); var validChildren = new List<BaseItem>(); @@ -369,6 +386,11 @@ namespace MediaBrowser.Controller.Entities foreach (var child in nonCachedChildren) { + if (!IsLibraryFolderAccessible(directoryService, child)) + { + continue; + } + if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild)) { validChildren.Add(currentChild); @@ -392,8 +414,8 @@ namespace MediaBrowser.Controller.Entities validChildren.Add(child); } - // If any items were added or removed.... - if (newItems.Count > 0 || currentChildren.Count != validChildren.Count) + // If it's an AggregateFolder, don't remove + if (!IsRoot && currentChildren.Count != validChildren.Count) { // That's all the new and changed ones - now see if there are any that are missing var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); @@ -408,7 +430,10 @@ namespace MediaBrowser.Controller.Entities LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false); } } + } + if (newItems.Count > 0) + { LibraryManager.CreateItems(newItems, this, cancellationToken); } } @@ -435,15 +460,7 @@ namespace MediaBrowser.Controller.Entities progress.Report(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"); - } + ProviderManager.OnRefreshProgress(folder, percent); }); if (validChildrenNeedGeneration) @@ -475,15 +492,7 @@ namespace MediaBrowser.Controller.Entities if (recursive) { - // 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"); - } + ProviderManager.OnRefreshProgress(folder, percent); } }); diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs index bace703ad..44a1a85e3 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs @@ -138,7 +138,7 @@ namespace MediaBrowser.Controller.Library MediaProtocol GetPathProtocol(string path); - void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user); + void SetDefaultAudioAndSubtitleStreamIndices(BaseItem item, MediaSourceInfo source, User user); Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, string cacheKey, bool addProbeDelay, bool isLiveStream, CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Library/IMusicManager.cs b/MediaBrowser.Controller/Library/IMusicManager.cs index ec34a868b..93073cc79 100644 --- a/MediaBrowser.Controller/Library/IMusicManager.cs +++ b/MediaBrowser.Controller/Library/IMusicManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CA1002, CS1591 using System.Collections.Generic; @@ -19,7 +17,7 @@ namespace MediaBrowser.Controller.Library /// <param name="user">The user to use.</param> /// <param name="dtoOptions">The options to use.</param> /// <returns>List of items.</returns> - List<BaseItem> GetInstantMixFromItem(BaseItem item, User user, DtoOptions dtoOptions); + List<BaseItem> GetInstantMixFromItem(BaseItem item, User? user, DtoOptions dtoOptions); /// <summary> /// Gets the instant mix from artist. @@ -28,7 +26,7 @@ namespace MediaBrowser.Controller.Library /// <param name="user">The user to use.</param> /// <param name="dtoOptions">The options to use.</param> /// <returns>List of items.</returns> - List<BaseItem> GetInstantMixFromArtist(MusicArtist artist, User user, DtoOptions dtoOptions); + List<BaseItem> GetInstantMixFromArtist(MusicArtist artist, User? user, DtoOptions dtoOptions); /// <summary> /// Gets the instant mix from genre. @@ -37,6 +35,6 @@ namespace MediaBrowser.Controller.Library /// <param name="user">The user to use.</param> /// <param name="dtoOptions">The options to use.</param> /// <returns>List of items.</returns> - List<BaseItem> GetInstantMixFromGenres(IEnumerable<string> genres, User user, DtoOptions dtoOptions); + List<BaseItem> GetInstantMixFromGenres(IEnumerable<string> genres, User? user, DtoOptions dtoOptions); } } diff --git a/MediaBrowser.Controller/LiveTv/ChannelInfo.cs b/MediaBrowser.Controller/LiveTv/ChannelInfo.cs index 699c15f93..52581df45 100644 --- a/MediaBrowser.Controller/LiveTv/ChannelInfo.cs +++ b/MediaBrowser.Controller/LiveTv/ChannelInfo.cs @@ -54,7 +54,7 @@ namespace MediaBrowser.Controller.LiveTv public string ChannelGroup { get; set; } /// <summary> - /// Gets or sets the the image path if it can be accessed directly from the file system. + /// Gets or sets the image path if it can be accessed directly from the file system. /// </summary> /// <value>The image path.</value> public string ImagePath { get; set; } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index b6738e7cc..eb375c8a2 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1,6 +1,8 @@ #nullable disable #pragma warning disable CS1591 +// We need lowercase normalized string for ffmpeg +#pragma warning disable CA1308 using System; using System.Collections.Generic; @@ -26,6 +28,14 @@ namespace MediaBrowser.Controller.MediaEncoding { public partial class EncodingHelper { + /// <summary> + /// The codec validation regex. + /// This regular expression matches strings that consist of alphanumeric characters, hyphens, + /// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters. + /// This should matches all common valid codecs. + /// </summary> + public const string ValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$"; + private const string QsvAlias = "qs"; private const string VaapiAlias = "va"; private const string D3d11vaAlias = "dx11"; @@ -51,6 +61,9 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegOclCuTonemapMode = new Version(5, 1, 3); private readonly Version _minFFmpegSvtAv1Params = new Version(5, 1); private readonly Version _minFFmpegVaapiH26xEncA53CcSei = new Version(6, 0); + private readonly Version _minFFmpegReadrateOption = new Version(5, 0); + + private static readonly Regex _validationRegex = new(ValidationRegex, RegexOptions.Compiled); private static readonly string[] _videoProfilesH264 = new[] { @@ -94,7 +107,6 @@ namespace MediaBrowser.Controller.MediaEncoding { "wmav2", 2 }, { "libmp3lame", 2 }, { "libfdk_aac", 6 }, - { "aac_at", 6 }, { "ac3", 6 }, { "eac3", 6 }, { "dca", 6 }, @@ -253,6 +265,15 @@ namespace MediaBrowser.Controller.MediaEncoding && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayVulkanFrameSync); } + private bool IsVideoToolboxFullSupported() + { + return _mediaEncoder.SupportsHwaccel("videotoolbox") + && _mediaEncoder.SupportsFilter("yadif_videotoolbox") + && _mediaEncoder.SupportsFilter("overlay_videotoolbox") + && _mediaEncoder.SupportsFilter("tonemap_videotoolbox") + && _mediaEncoder.SupportsFilter("scale_vt"); + } + private bool IsHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream is null @@ -272,12 +293,15 @@ namespace MediaBrowser.Controller.MediaEncoding var isNvdecDecoder = vidDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isD3d11vaDecoder = vidDecoder.Contains("d3d11va", StringComparison.OrdinalIgnoreCase); - return isSwDecoder || isNvdecDecoder || isVaapiDecoder || isD3d11vaDecoder; + var isVideoToolBoxDecoder = vidDecoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase); + return isSwDecoder || isNvdecDecoder || isVaapiDecoder || isD3d11vaDecoder || isVideoToolBoxDecoder; } return state.VideoStream.VideoRange == VideoRange.HDR && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 - || state.VideoStream.VideoRangeType == VideoRangeType.HLG); + || state.VideoStream.VideoRangeType == VideoRangeType.HLG + || state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10 + || state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG); } private bool IsVulkanHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) @@ -305,7 +329,23 @@ namespace MediaBrowser.Controller.MediaEncoding // Native VPP tonemapping may come to QSV in the future. return state.VideoStream.VideoRange == VideoRange.HDR - && state.VideoStream.VideoRangeType == VideoRangeType.HDR10; + && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 + || state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10); + } + + private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options) + { + if (state.VideoStream is null + || !options.EnableVideoToolboxTonemapping + || GetVideoColorBitDepth(state) != 10) + { + return false; + } + + // Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding. + // All other HDR formats working. + return state.VideoStream.VideoRange == VideoRange.HDR + && state.VideoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG or VideoRangeType.HDR10Plus or VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithHLG; } /// <summary> @@ -362,7 +402,10 @@ namespace MediaBrowser.Controller.MediaEncoding return "libtheora"; } - return codec.ToLowerInvariant(); + if (_validationRegex.IsMatch(codec)) + { + return codec.ToLowerInvariant(); + } } return "copy"; @@ -400,7 +443,7 @@ namespace MediaBrowser.Controller.MediaEncoding public static string GetInputFormat(string container) { - if (string.IsNullOrEmpty(container)) + if (string.IsNullOrEmpty(container) || !_validationRegex.IsMatch(container)) { return null; } @@ -656,6 +699,11 @@ namespace MediaBrowser.Controller.MediaEncoding { var codec = state.OutputAudioCodec; + if (!_validationRegex.IsMatch(codec)) + { + codec = "aac"; + } + if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)) { // Use Apple's aac encoder if available as it provides best audio quality @@ -703,6 +751,15 @@ namespace MediaBrowser.Controller.MediaEncoding return "dca"; } + if (string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase)) + { + // The ffmpeg upstream breaks the AudioToolbox ALAC encoder in version 6.1 but fixes it in version 7.0. + // Since ALAC is lossless in quality and the AudioToolbox encoder is not faster, + // its only benefit is a smaller file size. + // To prevent problems, use the ffmpeg native encoder instead. + return "alac"; + } + return codec.ToLowerInvariant(); } @@ -1071,7 +1128,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Empty; } - // no videotoolbox hw filter. + // videotoolbox hw filter does not require device selection args.Append(GetVideoToolboxDeviceArgs(VideotoolboxAlias)); } else if (string.Equals(optHwaccelType, "rkmpp", StringComparison.OrdinalIgnoreCase)) @@ -1197,7 +1254,7 @@ namespace MediaBrowser.Controller.MediaEncoding // Disable auto inserted SW scaler for HW decoders in case of changed resolution. var isSwDecoder = string.IsNullOrEmpty(GetHardwareVideoDecoder(state, options)); - if (!isSwDecoder && _mediaEncoder.EncoderVersion >= new Version(4, 4)) + if (!isSwDecoder) { arg.Append(" -noautoscale"); } @@ -1214,23 +1271,23 @@ namespace MediaBrowser.Controller.MediaEncoding { var codec = stream.Codec ?? string.Empty; - return codec.IndexOf("264", StringComparison.OrdinalIgnoreCase) != -1 - || codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1; + return codec.Contains("264", StringComparison.OrdinalIgnoreCase) + || codec.Contains("avc", StringComparison.OrdinalIgnoreCase); } public static bool IsH265(MediaStream stream) { var codec = stream.Codec ?? string.Empty; - return codec.IndexOf("265", StringComparison.OrdinalIgnoreCase) != -1 - || codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1; + return codec.Contains("265", StringComparison.OrdinalIgnoreCase) + || codec.Contains("hevc", StringComparison.OrdinalIgnoreCase); } public static bool IsAAC(MediaStream stream) { var codec = stream.Codec ?? string.Empty; - return codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1; + return codec.Contains("aac", StringComparison.OrdinalIgnoreCase); } public static string GetBitStreamArgs(MediaStream stream) @@ -1284,7 +1341,7 @@ namespace MediaBrowser.Controller.MediaEncoding return ".ts"; } - public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec) + private string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec) { if (state.OutputVideoBitrate is null) { @@ -1348,6 +1405,14 @@ namespace MediaBrowser.Controller.MediaEncoding return FormattableString.Invariant($" -rc_mode VBR -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } + if (string.Equals(videoCodec, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "hevc_videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + // The `maxrate` and `bufsize` options can potentially lead to performance regression + // and even encoder hangs, especially when the value is very high. + return FormattableString.Invariant($" -b:v {bitrate} -qmin -1 -qmax -1"); + } + return FormattableString.Invariant($" -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } @@ -1818,6 +1883,31 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -gops_per_idr 1"; } } + else if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase) // h264 (h264_videotoolbox) + || string.Equals(videoEncoder, "hevc_videotoolbox", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_videotoolbox) + { + switch (encodingOptions.EncoderPreset) + { + case "veryslow": + case "slower": + case "slow": + case "medium": + param += " -prio_speed 0"; + break; + + case "fast": + case "faster": + case "veryfast": + case "superfast": + case "ultrafast": + param += " -prio_speed 1"; + break; + + default: + param += " -prio_speed 1"; + break; + } + } else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // vp8 { // Values 0-3, 0 being highest quality but slower @@ -2181,7 +2271,16 @@ namespace MediaBrowser.Controller.MediaEncoding return false; } - if (!requestedRangeTypes.Contains(videoStream.VideoRangeType.ToString(), StringComparison.OrdinalIgnoreCase)) + // DOVIWithHDR10 should be compatible with HDR10 supporting players. Same goes with HLG and of course SDR. So allow copy of those formats + + var requestHasHDR10 = requestedRangeTypes.Contains(VideoRangeType.HDR10.ToString(), StringComparison.OrdinalIgnoreCase); + var requestHasHLG = requestedRangeTypes.Contains(VideoRangeType.HLG.ToString(), StringComparison.OrdinalIgnoreCase); + var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase); + + if (!requestedRangeTypes.Contains(videoStream.VideoRangeType.ToString(), StringComparison.OrdinalIgnoreCase) + && !((requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10) + || (requestHasHLG && videoStream.VideoRangeType == VideoRangeType.DOVIWithHLG) + || (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR))) { return false; } @@ -4954,22 +5053,29 @@ namespace MediaBrowser.Controller.MediaEncoding return (null, null, null); } - var swFilterChain = GetSwVidFilterChain(state, options, vidEncoder); + var isMacOS = OperatingSystem.IsMacOS(); + var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + var isVtEncoder = vidEncoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase); + var isVtFullSupported = isMacOS && IsVideoToolboxFullSupported(); - if (!options.EnableHardwareEncoding) + // legacy videotoolbox pipeline (disable hw filters) + if (!isVtEncoder + || !isVtFullSupported + || !_mediaEncoder.SupportsFilter("alphasrc")) { - return swFilterChain; + return GetSwVidFilterChain(state, options, vidEncoder); } - if (_mediaEncoder.EncoderVersion.CompareTo(new Version("5.0.0")) < 0) - { - // All features used here requires ffmpeg 5.0 or later, fallback to software filters if using an old ffmpeg - return swFilterChain; - } + // preferred videotoolbox + metal filters pipeline + return GetAppleVidFiltersPreferred(state, options, vidDecoder, vidEncoder); + } - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetAppleVidFiltersPreferred( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { var inW = state.VideoStream?.Width; var inH = state.VideoStream?.Height; var reqW = state.BaseRequest.Width; @@ -4977,33 +5083,121 @@ namespace MediaBrowser.Controller.MediaEncoding var reqMaxW = state.BaseRequest.MaxWidth; var reqMaxH = state.BaseRequest.MaxHeight; var threeDFormat = state.MediaSource.Video3DFormat; - var newfilters = new List<string>(); - var noOverlay = swFilterChain.OverlayFilters.Count == 0; - var supportsHwDeint = _mediaEncoder.SupportsFilter("yadif_videotoolbox"); - // fallback to software filters if we are using filters not supported by hardware yet. - var useHardwareFilters = noOverlay && (!doDeintH2645 || supportsHwDeint); - if (!useHardwareFilters) + var isVtEncoder = vidEncoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase); + + 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 doVtTonemap = IsVideoToolboxTonemapAvailable(state, options); + var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options); + + var scaleFormat = string.Empty; + // Use P010 for Metal tone mapping, otherwise force an 8bit output. + if (!string.Equals(state.VideoStream.PixelFormat, "yuv420p", StringComparison.OrdinalIgnoreCase)) { - return swFilterChain; + if (doMetalTonemap) + { + if (!string.Equals(state.VideoStream.PixelFormat, "yuv420p10le", StringComparison.OrdinalIgnoreCase)) + { + scaleFormat = "p010le"; + } + } + else + { + scaleFormat = "nv12"; + } } - // ffmpeg cannot use videotoolbox to scale - var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); - newfilters.Add(swScaleFilter); + var hwScaleFilter = GetHwScaleFilter("vt", scaleFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + var hasSubs = state.SubtitleStream is not 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)); + + if (!isVtEncoder) + { + // should not happen. + return (null, null, null); + } - // hwupload on videotoolbox encoders can automatically convert AVFrame into its CVPixelBuffer equivalent - // videotoolbox will automatically convert the CVPixelBuffer to a pixel format the encoder supports, so we don't have to set a pixel format explicitly here - // This will reduce CPU usage significantly on UHD videos with 10 bit colors because we bypassed the ffmpeg pixel format conversion - newfilters.Add("hwupload"); + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + // hw deint if (doDeintH2645) { var deintFilter = GetHwDeinterlaceFilter(state, options, "videotoolbox"); - newfilters.Add(deintFilter); + mainFilters.Add(deintFilter); + } + + if (doVtTonemap) + { + const string VtTonemapArgs = "color_matrix=bt709:color_primaries=bt709:color_transfer=bt709"; + + // scale_vt can handle scaling & tonemapping in one shot, just like vpp_qsv. + hwScaleFilter = string.IsNullOrEmpty(hwScaleFilter) + ? "scale_vt=" + VtTonemapArgs + : hwScaleFilter + ":" + VtTonemapArgs; + } + + // hw scale & vt tonemap + mainFilters.Add(hwScaleFilter); + + // Metal tonemap + if (doMetalTonemap) + { + var tonemapFilter = GetHwTonemapFilter(options, "videotoolbox", "nv12"); + mainFilters.Add(tonemapFilter); + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + + 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; + + 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=videotoolbox"); + overlayFilters.Add("overlay_videotoolbox=eof_action=pass:repeatlast=0"); } - return (newfilters, swFilterChain.SubFilters, swFilterChain.OverlayFilters); + var needFiltering = mainFilters.Any(f => !string.IsNullOrEmpty(f)) || + subFilters.Any(f => !string.IsNullOrEmpty(f)) || + overlayFilters.Any(f => !string.IsNullOrEmpty(f)); + + // This is a workaround for ffmpeg's hwupload implementation + // For VideoToolbox encoders, a hwupload without a valid filter actually consuming its frame + // will cause the encoder to produce incorrect frames. + if (needFiltering) + { + // INPUT videotoolbox/memory surface(vram/uma) + // this will pass-through automatically if in/out format matches. + mainFilters.Insert(0, "format=nv12|p010le|videotoolbox_vld"); + mainFilters.Insert(0, "hwupload=derive_device=videotoolbox"); + } + + return (mainFilters, subFilters, overlayFilters); } /// <summary> @@ -5995,22 +6189,22 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals("yuvj420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); var is8_10bitSwFormatsVt = is8bitSwFormatsVt || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + // VideoToolbox's Hardware surface in ffmpeg is not only slower than hwupload, but also breaks HDR in many cases. + // For example: https://trac.ffmpeg.org/ticket/10884 + // Disable it for now. + const bool UseHwSurface = false; + if (is8bitSwFormatsVt) { if (string.Equals("avc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) || string.Equals("h264", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) { - return GetHwaccelType(state, options, "h264", bitDepth, false); - } - - if (string.Equals("mpeg2video", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) - { - return GetHwaccelType(state, options, "mpeg2video", bitDepth, false); + return GetHwaccelType(state, options, "h264", bitDepth, UseHwSurface); } - if (string.Equals("mpeg4", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + if (string.Equals("vp8", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) { - return GetHwaccelType(state, options, "mpeg4", bitDepth, false); + return GetHwaccelType(state, options, "vp8", bitDepth, UseHwSurface); } } @@ -6019,12 +6213,12 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) { - return GetHwaccelType(state, options, "hevc", bitDepth, false); + return GetHwaccelType(state, options, "hevc", bitDepth, UseHwSurface); } if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) { - return GetHwaccelType(state, options, "vp9", bitDepth, false); + return GetHwaccelType(state, options, "vp9", bitDepth, UseHwSurface); } } @@ -6265,6 +6459,16 @@ namespace MediaBrowser.Controller.MediaEncoding { inputModifier += " -re"; } + else if (encodingOptions.EnableSegmentDeletion + && state.VideoStream is not null + && state.TranscodingType == TranscodingJobType.Hls + && IsCopyCodec(state.OutputVideoCodec) + && _mediaEncoder.EncoderVersion >= _minFFmpegReadrateOption) + { + // Set an input read rate limit 10x for using SegmentDeletion with stream-copy + // to prevent ffmpeg from exiting prematurely (due to fast drive) + inputModifier += " -readrate 10"; + } var flags = new List<string>(); if (state.IgnoreInputDts) @@ -6464,7 +6668,7 @@ namespace MediaBrowser.Controller.MediaEncoding while (shiftAudioCodecs.Contains(audioCodecs[0], StringComparison.OrdinalIgnoreCase)) { - var removed = shiftAudioCodecs[0]; + var removed = audioCodecs[0]; audioCodecs.RemoveAt(0); audioCodecs.Add(removed); } @@ -6498,7 +6702,7 @@ namespace MediaBrowser.Controller.MediaEncoding while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparison.OrdinalIgnoreCase)) { - var removed = shiftVideoCodecs[0]; + var removed = videoCodecs[0]; videoCodecs.RemoveAt(0); videoCodecs.Add(removed); } diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index c2cef4978..e696fa52c 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -149,6 +149,7 @@ namespace MediaBrowser.Controller.MediaEncoding /// <param name="maxWidth">The maximum width.</param> /// <param name="interval">The interval.</param> /// <param name="allowHwAccel">Allow for hardware acceleration.</param> + /// <param name="enableHwEncoding">Use hardware mjpeg encoder.</param> /// <param name="threads">The input/output thread count for ffmpeg.</param> /// <param name="qualityScale">The qscale value for ffmpeg.</param> /// <param name="priority">The process priority for the ffmpeg process.</param> @@ -163,6 +164,7 @@ namespace MediaBrowser.Controller.MediaEncoding int maxWidth, TimeSpan interval, bool allowHwAccel, + bool enableHwEncoding, int? threads, int? qualityScale, ProcessPriorityClass? priority, diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs index 1e6d5933c..2b6540ea8 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs @@ -137,6 +137,11 @@ public sealed class TranscodingJob : IDisposable public TranscodingThrottler? TranscodingThrottler { get; set; } /// <summary> + /// Gets or sets transcoding segment cleaner. + /// </summary> + public TranscodingSegmentCleaner? TranscodingSegmentCleaner { get; set; } + + /// <summary> /// Gets or sets last ping date. /// </summary> public DateTime LastPingDate { get; set; } @@ -239,6 +244,7 @@ public sealed class TranscodingJob : IDisposable { #pragma warning disable CA1849 // Can't await in lock block TranscodingThrottler?.Stop().GetAwaiter().GetResult(); + TranscodingSegmentCleaner?.Stop(); var process = Process; @@ -276,5 +282,7 @@ public sealed class TranscodingJob : IDisposable CancellationTokenSource = null; TranscodingThrottler?.Dispose(); TranscodingThrottler = null; + TranscodingSegmentCleaner?.Dispose(); + TranscodingSegmentCleaner = null; } } diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs new file mode 100644 index 000000000..67bfcb02f --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// <summary> +/// Transcoding segment cleaner. +/// </summary> +public class TranscodingSegmentCleaner : IDisposable +{ + private readonly TranscodingJob _job; + private readonly ILogger<TranscodingSegmentCleaner> _logger; + private readonly IConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IMediaEncoder _mediaEncoder; + private Timer? _timer; + private int _segmentLength; + + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingSegmentCleaner"/> class. + /// </summary> + /// <param name="job">Transcoding job dto.</param> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingSegmentCleaner}"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="segmentLength">The segment length of this transcoding job.</param> + public TranscodingSegmentCleaner(TranscodingJob job, ILogger<TranscodingSegmentCleaner> logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder, int segmentLength) + { + _job = job; + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _segmentLength = segmentLength; + } + + /// <summary> + /// Start timer. + /// </summary> + public void Start() + { + _timer = new Timer(TimerCallback, null, 20000, 20000); + } + + /// <summary> + /// Stop cleaner. + /// </summary> + public void Stop() + { + DisposeTimer(); + } + + /// <summary> + /// Dispose cleaner. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose cleaner. + /// </summary> + /// <param name="disposing">Disposing.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + DisposeTimer(); + } + } + + private EncodingOptions GetOptions() + { + return _config.GetEncodingOptions(); + } + + private async void TimerCallback(object? state) + { + if (_job.HasExited) + { + DisposeTimer(); + return; + } + + var options = GetOptions(); + var enableSegmentDeletion = options.EnableSegmentDeletion; + var segmentKeepSeconds = Math.Max(options.SegmentKeepSeconds, 20); + + if (enableSegmentDeletion) + { + var downloadPositionTicks = _job.DownloadPositionTicks ?? 0; + var downloadPositionSeconds = Convert.ToInt64(TimeSpan.FromTicks(downloadPositionTicks).TotalSeconds); + + if (downloadPositionSeconds > 0 && segmentKeepSeconds > 0 && downloadPositionSeconds > segmentKeepSeconds) + { + var idxMaxToDelete = (downloadPositionSeconds - segmentKeepSeconds) / _segmentLength; + + if (idxMaxToDelete > 0) + { + await DeleteSegmentFiles(_job, 0, idxMaxToDelete, 1500).ConfigureAwait(false); + } + } + } + } + + private async Task DeleteSegmentFiles(TranscodingJob job, long idxMin, long idxMax, int delayMs) + { + var path = job.Path ?? throw new ArgumentException("Path can't be null."); + + _logger.LogDebug("Deleting segment file(s) index {Min} to {Max} from {Path}", idxMin, idxMax, path); + + await Task.Delay(delayMs).ConfigureAwait(false); + + try + { + if (job.Type == TranscodingJobType.Hls) + { + DeleteHlsSegmentFiles(path, idxMin, idxMax); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error deleting segment file(s) {Path}", path); + } + } + + private void DeleteHlsSegmentFiles(string outputFilePath, long idxMin, long idxMax) + { + var directory = Path.GetDirectoryName(outputFilePath) + ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + + var name = Path.GetFileNameWithoutExtension(outputFilePath); + + var filesToDelete = _fileSystem.GetFilePaths(directory) + .Where(f => long.TryParse(Path.GetFileNameWithoutExtension(f).Replace(name, string.Empty, StringComparison.Ordinal), out var idx) + && (idx >= idxMin && idx <= idxMax)); + + List<Exception>? exs = null; + foreach (var file in filesToDelete) + { + try + { + _logger.LogDebug("Deleting HLS segment file {0}", file); + _fileSystem.DeleteFile(file); + } + catch (IOException ex) + { + (exs ??= new List<Exception>()).Add(ex); + _logger.LogDebug(ex, "Error deleting HLS segment file {Path}", file); + } + } + + if (exs is not null) + { + throw new AggregateException("Error deleting HLS segment files", exs); + } + } + + private void DisposeTimer() + { + if (_timer is not null) + { + _timer.Dispose(); + _timer = null; + } + } +} diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs index 813f13eae..b95e6ed51 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs @@ -115,7 +115,7 @@ public class TranscodingThrottler : IDisposable var options = GetOptions(); - if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) + if (options.EnableThrottling && IsThrottleAllowed(_job, Math.Max(options.ThrottleDelaySeconds, 60))) { await PauseTranscoding().ConfigureAwait(false); } diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 0a706c307..06386f2b8 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Linq; using System.Net.WebSockets; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using MediaBrowser.Controller.Net.WebSocketMessages; using MediaBrowser.Model.Session; @@ -21,26 +22,38 @@ namespace MediaBrowser.Controller.Net /// </summary> /// <typeparam name="TReturnDataType">The type of the T return data type.</typeparam> /// <typeparam name="TStateType">The type of the T state type.</typeparam> - public abstract class BasePeriodicWebSocketListener<TReturnDataType, TStateType> : IWebSocketListener, IDisposable + public abstract class BasePeriodicWebSocketListener<TReturnDataType, TStateType> : IWebSocketListener, IAsyncDisposable where TStateType : WebSocketListenerState, new() where TReturnDataType : class { + private readonly Channel<bool> _channel = Channel.CreateUnbounded<bool>(new UnboundedChannelOptions + { + AllowSynchronousContinuations = false, + SingleReader = true, + SingleWriter = false + }); + + private readonly object _activeConnectionsLock = new(); + /// <summary> /// The _active connections. /// </summary> - private readonly List<Tuple<IWebSocketConnection, CancellationTokenSource, TStateType>> _activeConnections = - new List<Tuple<IWebSocketConnection, CancellationTokenSource, TStateType>>(); + private readonly List<(IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)> _activeConnections = new(); /// <summary> /// The logger. /// </summary> protected readonly ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; + private readonly Task _messageConsumerTask; + protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) { ArgumentNullException.ThrowIfNull(logger); Logger = logger; + + _messageConsumerTask = HandleMessages(); } /// <summary> @@ -113,75 +126,93 @@ namespace MediaBrowser.Controller.Net InitialDelayMs = dueTimeMs }; - lock (_activeConnections) + lock (_activeConnectionsLock) { - _activeConnections.Add(new Tuple<IWebSocketConnection, CancellationTokenSource, TStateType>(message.Connection, cancellationTokenSource, state)); + _activeConnections.Add((message.Connection, cancellationTokenSource, state)); } } - protected async Task SendData(bool force) + protected void SendData(bool force) { - Tuple<IWebSocketConnection, CancellationTokenSource, TStateType>[] tuples; + _channel.Writer.TryWrite(force); + } - lock (_activeConnections) + private async Task HandleMessages() + { + while (await _channel.Reader.WaitToReadAsync().ConfigureAwait(false)) { - tuples = _activeConnections - .Where(c => + while (_channel.Reader.TryRead(out var force)) + { + try { - if (c.Item1.State == WebSocketState.Open && !c.Item2.IsCancellationRequested) - { - var state = c.Item3; + (IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)[] tuples; - if (force || (DateTime.UtcNow - state.DateLastSendUtc).TotalMilliseconds >= state.IntervalMs) + var now = DateTime.UtcNow; + lock (_activeConnectionsLock) + { + if (_activeConnections.Count == 0) { - return true; + continue; } + + tuples = _activeConnections + .Where(c => + { + if (c.Connection.State != WebSocketState.Open || c.CancellationTokenSource.IsCancellationRequested) + { + return false; + } + + var state = c.State; + return force || (now - state.DateLastSendUtc).TotalMilliseconds >= state.IntervalMs; + }) + .ToArray(); } - return false; - }) - .ToArray(); - } + if (tuples.Length == 0) + { + continue; + } - IEnumerable<Task> GetTasks() - { - foreach (var tuple in tuples) - { - yield return SendData(tuple); + var data = await GetDataToSend().ConfigureAwait(false); + if (data is null) + { + continue; + } + + IEnumerable<Task> GetTasks() + { + foreach (var tuple in tuples) + { + yield return SendDataInternal(data, tuple); + } + } + + await Task.WhenAll(GetTasks()).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to send updates to websockets"); + } } } - - await Task.WhenAll(GetTasks()).ConfigureAwait(false); } - private async Task SendData(Tuple<IWebSocketConnection, CancellationTokenSource, TStateType> tuple) + private async Task SendDataInternal(TReturnDataType data, (IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State) tuple) { - var connection = tuple.Item1; - try { - var state = tuple.Item3; + var (connection, cts, state) = tuple; + var cancellationToken = cts.Token; + await connection.SendAsync( + new OutboundWebSocketMessage<TReturnDataType> { MessageType = Type, Data = data }, + cancellationToken).ConfigureAwait(false); - var cancellationToken = tuple.Item2.Token; - - var data = await GetDataToSend().ConfigureAwait(false); - - if (data is not null) - { - await connection.SendAsync( - new OutboundWebSocketMessage<TReturnDataType> - { - MessageType = Type, - Data = data - }, - cancellationToken).ConfigureAwait(false); - - state.DateLastSendUtc = DateTime.UtcNow; - } + state.DateLastSendUtc = DateTime.UtcNow; } catch (OperationCanceledException) { - if (tuple.Item2.IsCancellationRequested) + if (tuple.CancellationTokenSource.IsCancellationRequested) { DisposeConnection(tuple); } @@ -199,11 +230,11 @@ namespace MediaBrowser.Controller.Net /// <param name="message">The message.</param> private void Stop(WebSocketMessageInfo message) { - lock (_activeConnections) + lock (_activeConnectionsLock) { - var connection = _activeConnections.FirstOrDefault(c => c.Item1 == message.Connection); + var connection = _activeConnections.FirstOrDefault(c => c.Connection == message.Connection); - if (connection is not null) + if (connection != default) { DisposeConnection(connection); } @@ -214,17 +245,17 @@ namespace MediaBrowser.Controller.Net /// Disposes the connection. /// </summary> /// <param name="connection">The connection.</param> - private void DisposeConnection(Tuple<IWebSocketConnection, CancellationTokenSource, TStateType> connection) + private void DisposeConnection((IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State) connection) { - Logger.LogDebug("WS {1} stop transmitting to {0}", connection.Item1.RemoteEndPoint, GetType().Name); + Logger.LogDebug("WS {1} stop transmitting to {0}", connection.Connection.RemoteEndPoint, GetType().Name); // TODO disposing the connection seems to break websockets in subtle ways, so what is the purpose of this function really... // connection.Item1.Dispose(); try { - connection.Item2.Cancel(); - connection.Item2.Dispose(); + connection.CancellationTokenSource.Cancel(); + connection.CancellationTokenSource.Dispose(); } catch (ObjectDisposedException ex) { @@ -237,36 +268,37 @@ namespace MediaBrowser.Controller.Net Logger.LogError(ex, "Error disposing websocket"); } - lock (_activeConnections) + lock (_activeConnectionsLock) { _activeConnections.Remove(connection); } } - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) + protected virtual async ValueTask DisposeAsyncCore() { - if (dispose) + try { - lock (_activeConnections) + _channel.Writer.TryComplete(); + await _messageConsumerTask.ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Disposing the message consumer failed"); + } + + lock (_activeConnectionsLock) + { + foreach (var connection in _activeConnections.ToArray()) { - foreach (var connection in _activeConnections.ToArray()) - { - DisposeConnection(connection); - } + DisposeConnection(connection); } } } - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> - public void Dispose() + /// <inheritdoc /> + public async ValueTask DisposeAsync() { - Dispose(true); + await DisposeAsyncCore().ConfigureAwait(false); GC.SuppressFinalize(this); } } diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index bb68a3b6d..cbe4bd87f 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Playlists; namespace MediaBrowser.Controller.Playlists @@ -11,6 +12,28 @@ namespace MediaBrowser.Controller.Playlists public interface IPlaylistManager { /// <summary> + /// Gets the playlist. + /// </summary> + /// <param name="playlistId">The playlist identifier.</param> + /// <param name="userId">The user identifier.</param> + /// <returns>Playlist.</returns> + Playlist GetPlaylistForUser(Guid playlistId, Guid userId); + + /// <summary> + /// Creates the playlist. + /// </summary> + /// <param name="request">The <see cref="PlaylistCreationRequest"/>.</param> + /// <returns>The created playlist.</returns> + Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest request); + + /// <summary> + /// Updates a playlist. + /// </summary> + /// <param name="request">The <see cref="PlaylistUpdateRequest"/>.</param> + /// <returns>Task.</returns> + Task UpdatePlaylist(PlaylistUpdateRequest request); + + /// <summary> /// Gets the playlists. /// </summary> /// <param name="userId">The user identifier.</param> @@ -18,11 +41,20 @@ namespace MediaBrowser.Controller.Playlists IEnumerable<Playlist> GetPlaylists(Guid userId); /// <summary> - /// Creates the playlist. + /// Adds a share to the playlist. + /// </summary> + /// <param name="request">The <see cref="PlaylistUserUpdateRequest"/>.</param> + /// <returns>Task.</returns> + Task AddUserToShares(PlaylistUserUpdateRequest request); + + /// <summary> + /// Removes a share from the playlist. /// </summary> - /// <param name="options">The options.</param> - /// <returns>Task<Playlist>.</returns> - Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options); + /// <param name="playlistId">The playlist identifier.</param> + /// <param name="userId">The user identifier.</param> + /// <param name="share">The share.</param> + /// <returns>Task.</returns> + Task RemoveUserFromShares(Guid playlistId, Guid userId, PlaylistUserPermissions share); /// <summary> /// Adds to playlist. @@ -31,7 +63,7 @@ namespace MediaBrowser.Controller.Playlists /// <param name="itemIds">The item ids.</param> /// <param name="userId">The user identifier.</param> /// <returns>Task.</returns> - Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId); + Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId); /// <summary> /// Removes from playlist. @@ -39,7 +71,7 @@ namespace MediaBrowser.Controller.Playlists /// <param name="playlistId">The playlist identifier.</param> /// <param name="entryIds">The entry ids.</param> /// <returns>Task.</returns> - Task RemoveFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds); + Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds); /// <summary> /// Gets the playlists folder. diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index ca032e7f6..34b34e578 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -16,24 +16,23 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Playlists { public class Playlist : Folder, IHasShares { - public static readonly IReadOnlyList<string> SupportedExtensions = new[] - { + public static readonly IReadOnlyList<string> SupportedExtensions = + [ ".m3u", ".m3u8", ".pls", ".wpl", ".zpl" - }; + ]; public Playlist() { - Shares = Array.Empty<Share>(); + Shares = []; OpenAccess = false; } @@ -41,7 +40,7 @@ namespace MediaBrowser.Controller.Playlists public bool OpenAccess { get; set; } - public Share[] Shares { get; set; } + public IReadOnlyList<PlaylistUserPermissions> Shares { get; set; } [JsonIgnore] public bool IsFile => IsPlaylistFile(Path); @@ -130,7 +129,7 @@ namespace MediaBrowser.Controller.Playlists protected override List<BaseItem> LoadChildren() { // Save a trip to the database - return new List<BaseItem>(); + return []; } protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) @@ -145,7 +144,7 @@ namespace MediaBrowser.Controller.Playlists protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService) { - return new List<BaseItem>(); + return []; } public override IEnumerable<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) @@ -167,7 +166,7 @@ namespace MediaBrowser.Controller.Playlists return base.GetChildren(user, true, query); } - public static List<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options) + public static IReadOnlyList<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options) { if (user is not null) { @@ -192,9 +191,9 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { BaseItemKind.Audio }, - GenreIds = new[] { musicGenre.Id }, - OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, + IncludeItemTypes = [BaseItemKind.Audio], + GenreIds = [musicGenre.Id], + OrderBy = [(ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending)], DtoOptions = options }); } @@ -204,9 +203,9 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { BaseItemKind.Audio }, - ArtistIds = new[] { musicArtist.Id }, - OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, + IncludeItemTypes = [BaseItemKind.Audio], + ArtistIds = [musicArtist.Id], + OrderBy = [(ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending)], DtoOptions = options }); } @@ -217,8 +216,8 @@ namespace MediaBrowser.Controller.Playlists { Recursive = true, IsFolder = false, - OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, - MediaTypes = new[] { mediaType }, + OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)], + MediaTypes = [mediaType], EnableTotalRecordCount = false, DtoOptions = options }; @@ -226,7 +225,7 @@ namespace MediaBrowser.Controller.Playlists return folder.GetItemList(query); } - return new[] { item }; + return [item]; } public override bool IsVisible(User user) @@ -248,12 +247,17 @@ namespace MediaBrowser.Controller.Playlists } var shares = Shares; - if (shares.Length == 0) + if (shares.Count == 0) { return false; } - return shares.Any(share => Guid.TryParse(share.UserId, out var id) && id.Equals(userId)); + return shares.Any(s => s.UserId.Equals(userId)); + } + + public override bool CanDelete(User user) + { + return user.HasPermission(PermissionKind.IsAdministrator) || user.Id.Equals(OwnerUserId); } public override bool IsVisibleStandalone(User user) diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index d4de97651..7fe2f64af 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -78,5 +78,10 @@ namespace MediaBrowser.Controller.Providers return filePaths; } + + public bool IsAccessible(string path) + { + return _fileSystem.GetFileSystemEntryPaths(path).Any(); + } } } diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index 48d627691..6d7550ab5 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -16,5 +16,7 @@ namespace MediaBrowser.Controller.Providers IReadOnlyList<string> GetFilePaths(string path); IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false); + + bool IsAccessible(string path); } } diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index 3a12a56f1..76d5d3a3f 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -134,6 +134,7 @@ namespace MediaBrowser.Controller.Session /// <value>The now playing item.</value> public BaseItemDto NowPlayingItem { get; set; } + [JsonIgnore] public BaseItem FullNowPlayingItem { get; set; } public BaseItemDto NowViewingItem { get; set; } |
