diff options
Diffstat (limited to 'MediaBrowser.Controller')
45 files changed, 1357 insertions, 321 deletions
diff --git a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs index 976a667ac..c993ceea8 100644 --- a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs +++ b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs @@ -14,8 +14,6 @@ namespace MediaBrowser.Controller.Authentication Task<ProviderAuthenticationResult> Authenticate(string username, string password); - bool HasPassword(User user); - Task ChangePassword(User user, string newPassword); } diff --git a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs index 592ce9955..36cd5c5d1 100644 --- a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs +++ b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -15,11 +13,12 @@ namespace MediaBrowser.Controller.Authentication bool IsEnabled { get; } - Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork); + Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork); Task<PinRedeemResult> RedeemPasswordResetPin(string pin); } +#nullable disable public class PasswordPinCreationResult { public string PinFile { get; set; } diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs new file mode 100644 index 000000000..25656fd62 --- /dev/null +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.Chapters; + +/// <summary> +/// Interface IChapterManager. +/// </summary> +public interface IChapterManager +{ + /// <summary> + /// Saves the chapters. + /// </summary> + /// <param name="video">The video.</param> + /// <param name="chapters">The set of chapters.</param> + void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters); + + /// <summary> + /// Gets a single chapter of a BaseItem on a specific index. + /// </summary> + /// <param name="baseItemId">The BaseItems id.</param> + /// <param name="index">The index of that chapter.</param> + /// <returns>A chapter instance.</returns> + ChapterInfo? GetChapter(Guid baseItemId, int index); + + /// <summary> + /// Gets all chapters associated with the baseItem. + /// </summary> + /// <param name="baseItemId">The BaseItems id.</param> + /// <returns>A readonly list of chapter instances.</returns> + IReadOnlyList<ChapterInfo> GetChapters(Guid baseItemId); + + /// <summary> + /// Refreshes the chapter images. + /// </summary> + /// <param name="video">Video to use.</param> + /// <param name="directoryService">Directory service to use.</param> + /// <param name="chapters">Set of chapters to refresh.</param> + /// <param name="extractImages">Option to extract images.</param> + /// <param name="saveChapters">Option to save chapters.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns><c>true</c> if successful, <c>false</c> if not.</returns> + Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken); + + /// <summary> + /// Deletes the chapter data. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index d1a6b3584..3c46d53e5 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -24,7 +24,9 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; @@ -34,7 +36,6 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities @@ -107,8 +108,15 @@ namespace MediaBrowser.Controller.Entities ProductionLocations = Array.Empty<string>(); RemoteTrailers = Array.Empty<MediaUrl>(); ExtraIds = Array.Empty<Guid>(); + UserData = []; } + /// <summary> + /// Gets or Sets the user data collection as cached from the last Db query. + /// </summary> + [JsonIgnore] + public ICollection<UserData> UserData { get; set; } + [JsonIgnore] public string PreferredMetadataCountryCode { get; set; } @@ -484,7 +492,7 @@ namespace MediaBrowser.Controller.Entities public static IItemRepository ItemRepository { get; set; } - public static IChapterRepository ChapterRepository { get; set; } + public static IChapterManager ChapterManager { get; set; } public static IFileSystem FileSystem { get; set; } @@ -701,19 +709,7 @@ namespace MediaBrowser.Controller.Entities { get { - var customRating = CustomRating; - if (!string.IsNullOrEmpty(customRating)) - { - return customRating; - } - - var parent = DisplayParent; - if (parent is not null) - { - return parent.CustomRatingForComparison; - } - - return null; + return GetCustomRatingForComparision(); } } @@ -791,6 +787,26 @@ namespace MediaBrowser.Controller.Entities /// <value>The remote trailers.</value> public IReadOnlyList<MediaUrl> RemoteTrailers { get; set; } + private string GetCustomRatingForComparision(HashSet<Guid> callstack = null) + { + callstack ??= new(); + var customRating = CustomRating; + if (!string.IsNullOrEmpty(customRating)) + { + return customRating; + } + + callstack.Add(Id); + + var parent = DisplayParent; + if (parent is not null && !callstack.Contains(parent.Id)) + { + return parent.GetCustomRatingForComparision(callstack); + } + + return null; + } + public virtual double GetDefaultPrimaryImageAspectRatio() { return 0; @@ -1112,6 +1128,15 @@ namespace MediaBrowser.Controller.Entities var protocol = item.PathProtocol; + // Resolve the item path so everywhere we use the media source it will always point to + // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link + // path will return null, so it's safe to check for all paths. + var itemPath = item.Path; + if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo) + { + itemPath = linkInfo.FullName; + } + var info = new MediaSourceInfo { Id = item.Id.ToString("N", CultureInfo.InvariantCulture), @@ -1119,7 +1144,7 @@ namespace MediaBrowser.Controller.Entities MediaStreams = MediaSourceManager.GetMediaStreams(item.Id), MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id), Name = GetMediaSourceName(item), - Path = enablePathSubstitution ? GetMappedPath(item, item.Path, protocol) : item.Path, + Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath, RunTimeTicks = item.RunTimeTicks, Container = item.Container, Size = item.Size, @@ -1266,7 +1291,7 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Overrides the base implementation to refresh metadata for local trailers. + /// The base implementation to refresh metadata. /// </summary> /// <param name="options">The options.</param> /// <param name="cancellationToken">The cancellation token.</param> @@ -1363,9 +1388,7 @@ namespace MediaBrowser.Controller.Entities protected virtual FileSystemMetadata[] GetFileSystemChildren(IDirectoryService directoryService) { - var path = ContainingFolderPath; - - return directoryService.GetFileSystemEntries(path); + return directoryService.GetFileSystemEntries(ContainingFolderPath); } private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) @@ -1394,6 +1417,23 @@ namespace MediaBrowser.Controller.Entities return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); }); + // Cleanup removed extras + var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray(); + if (removedExtraIds.Length > 0) + { + var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery() + { + ItemIds = removedExtraIds + }); + foreach (var removedExtra in removedExtras) + { + LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + { + DeleteFileLocation = false + }); + } + } + await Task.WhenAll(tasks).ConfigureAwait(false); item.ExtraIds = newExtraIds; @@ -1408,7 +1448,14 @@ namespace MediaBrowser.Controller.Entities public virtual bool RequiresRefresh() { - return false; + if (string.IsNullOrEmpty(Path) || DateModified == DateTime.MinValue) + { + return false; + } + + var info = FileSystem.GetFileSystemInfo(Path); + + return info.Exists && this.HasChanged(info.LastWriteTimeUtc); } public virtual List<string> GetUserDataKeys() @@ -1805,7 +1852,7 @@ namespace MediaBrowser.Controller.Entities public void SetStudios(IEnumerable<string> names) { - Studios = names.Trimmed().Distinct().ToArray(); + Studios = names.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } /// <summary> @@ -1971,9 +2018,10 @@ namespace MediaBrowser.Controller.Entities } // Remove from file system - if (info.IsLocalFile) + var path = info.Path; + if (info.IsLocalFile && !string.IsNullOrWhiteSpace(path)) { - FileSystem.DeleteFile(info.Path); + FileSystem.DeleteFile(path); } // Remove from item @@ -2051,7 +2099,7 @@ namespace MediaBrowser.Controller.Entities { if (imageType == ImageType.Chapter) { - var chapter = ChapterRepository.GetChapter(this.Id, imageIndex); + var chapter = ChapterManager.GetChapter(Id, imageIndex); if (chapter is null) { @@ -2101,7 +2149,7 @@ namespace MediaBrowser.Controller.Entities if (image.Type == ImageType.Chapter) { - var chapters = ChapterRepository.GetChapters(this.Id); + var chapters = ChapterManager.GetChapters(Id); for (var i = 0; i < chapters.Count; i++) { if (chapters[i].ImagePath == image.Path) @@ -2284,27 +2332,27 @@ namespace MediaBrowser.Controller.Entities return UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None); } - public virtual bool IsPlayed(User user) + public virtual bool IsPlayed(User user, UserItemData userItemData) { - var userdata = UserDataManager.GetUserData(user, this); + userItemData ??= UserDataManager.GetUserData(user, this); - return userdata is not null && userdata.Played; + return userItemData is not null && userItemData.Played; } - public bool IsFavoriteOrLiked(User user) + public bool IsFavoriteOrLiked(User user, UserItemData userItemData) { - var userdata = UserDataManager.GetUserData(user, this); + userItemData ??= UserDataManager.GetUserData(user, this); - return userdata is not null && (userdata.IsFavorite || (userdata.Likes ?? false)); + return userItemData is not null && (userItemData.IsFavorite || (userItemData.Likes ?? false)); } - public virtual bool IsUnplayed(User user) + public virtual bool IsUnplayed(User user, UserItemData userItemData) { ArgumentNullException.ThrowIfNull(user); - var userdata = UserDataManager.GetUserData(user, this); + userItemData ??= UserDataManager.GetUserData(user, this); - return userdata is null || !userdata.Played; + return userItemData is null || !userItemData.Played; } ItemLookupInfo IHasLookupInfo<ItemLookupInfo>.GetLookupInfo() diff --git a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs index dcd22a3b4..668e2c1e2 100644 --- a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs +++ b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs @@ -114,5 +114,19 @@ namespace MediaBrowser.Controller.Entities source.DeepCopy(dest); return dest; } + + /// <summary> + /// Determines if the item has changed. + /// </summary> + /// <param name="source">The source object.</param> + /// <param name="asOf">The timestamp to detect changes as of.</param> + /// <typeparam name="T">Source type.</typeparam> + /// <returns>Whether the item has changed.</returns> + public static bool HasChanged<T>(this T source, DateTime asOf) + where T : BaseItem + { + ArgumentNullException.ThrowIfNull(source); + return source.DateModified.Subtract(asOf).Duration().TotalSeconds > 1; + } } } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 4da22854b..151b957fe 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -11,7 +11,6 @@ using System.Security; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using System.Threading.Tasks.Dataflow; using J2N.Collections.Generic.Extensions; using Jellyfin.Data; using Jellyfin.Data.Enums; @@ -25,6 +24,7 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LibraryTaskScheduler; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; @@ -42,6 +42,8 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Folder : BaseItem { + private IEnumerable<BaseItem> _children; + public Folder() { LinkedChildren = Array.Empty<LinkedChild>(); @@ -49,6 +51,8 @@ namespace MediaBrowser.Controller.Entities public static IUserViewManager UserViewManager { get; set; } + public static ILimitedConcurrencyLibraryScheduler LimitedConcurrencyLibraryScheduler { get; set; } + /// <summary> /// Gets or sets a value indicating whether this instance is root. /// </summary> @@ -106,11 +110,15 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Gets the actual children. + /// Gets or Sets the actual children. /// </summary> /// <value>The actual children.</value> [JsonIgnore] - public virtual IEnumerable<BaseItem> Children => LoadChildren(); + public virtual IEnumerable<BaseItem> Children + { + get => _children ??= LoadChildren(); + set => _children = value; + } /// <summary> /// Gets thread-safe access to all recursive children of this folder - without regard to user. @@ -279,6 +287,7 @@ namespace MediaBrowser.Controller.Entities /// <returns>Task.</returns> public Task ValidateChildren(IProgress<double> progress, MetadataRefreshOptions metadataRefreshOptions, bool recursive = true, bool allowRemoveRoot = false, CancellationToken cancellationToken = default) { + Children = null; // invalidate cached children. return ValidateChildrenInternal(progress, recursive, true, allowRemoveRoot, metadataRefreshOptions, metadataRefreshOptions.DirectoryService, cancellationToken); } @@ -286,6 +295,7 @@ namespace MediaBrowser.Controller.Entities { var dictionary = new Dictionary<Guid, BaseItem>(); + Children = null; // invalidate cached children. var childrenList = Children.ToList(); foreach (var child in childrenList) @@ -327,6 +337,11 @@ namespace MediaBrowser.Controller.Entities try { + if (GetParents().Any(f => f.Id.Equals(Id))) + { + throw new InvalidOperationException("Recursive datastructure detected abort processing this item."); + } + await ValidateChildrenInternal2(progress, recursive, refreshChildMetadata, allowRemoveRoot, refreshOptions, directoryService, cancellationToken).ConfigureAwait(false); } finally @@ -442,6 +457,12 @@ namespace MediaBrowser.Controller.Entities { foreach (var item in itemsRemoved) { + if (!item.CanDelete()) + { + Logger.LogDebug("Item marked as non-removable, skipping: {Path}", item.Path ?? item.Name); + continue; + } + if (item.IsFileProtocol) { Logger.LogDebug("Removed item: {Path}", item.Path); @@ -524,6 +545,7 @@ namespace MediaBrowser.Controller.Entities { if (validChildrenNeedGeneration) { + Children = null; // invalidate cached children. validChildren = Children.ToList(); } @@ -566,7 +588,8 @@ namespace MediaBrowser.Controller.Entities if (recursive && child is Folder folder) { - await folder.RefreshMetadataRecursive(folder.Children.ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); + folder.Children = null; // invalidate cached children. + await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); } } } @@ -598,51 +621,13 @@ namespace MediaBrowser.Controller.Entities /// <returns>Task.</returns> private async Task RunTasks<T>(Func<T, IProgress<double>, Task> task, IList<T> children, IProgress<double> progress, CancellationToken cancellationToken) { - var childrenCount = children.Count; - var childrenProgress = new double[childrenCount]; - - void UpdateProgress() - { - progress.Report(childrenProgress.Average()); - } - - var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency; - var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : Environment.ProcessorCount; - - var actionBlock = new ActionBlock<int>( - async i => - { - var innerProgress = new Progress<double>(innerPercent => - { - // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls - var innerPercentRounded = Math.Round(innerPercent); - if (childrenProgress[i] != innerPercentRounded) - { - childrenProgress[i] = innerPercentRounded; - UpdateProgress(); - } - }); - - await task(children[i], innerProgress).ConfigureAwait(false); - - childrenProgress[i] = 100; - - UpdateProgress(); - }, - new ExecutionDataflowBlockOptions - { - MaxDegreeOfParallelism = parallelism, - CancellationToken = cancellationToken, - }); - - for (var i = 0; i < childrenCount; i++) - { - await actionBlock.SendAsync(i, cancellationToken).ConfigureAwait(false); - } - - actionBlock.Complete(); - - await actionBlock.Completion.ConfigureAwait(false); + await LimitedConcurrencyLibraryScheduler + .Enqueue( + children.ToArray(), + task, + progress, + cancellationToken) + .ConfigureAwait(false); } /// <summary> @@ -722,16 +707,29 @@ namespace MediaBrowser.Controller.Entities IEnumerable<BaseItem> items; Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); + var totalCount = 0; if (query.User is null) { items = GetRecursiveChildren(filter); + totalCount = items.Count(); } else { - items = GetRecursiveChildren(user, query); + // Save pagination params before clearing them to prevent pagination from happening + // before sorting. PostFilterAndSort will apply pagination after sorting. + var limit = query.Limit; + var startIndex = query.StartIndex; + query.Limit = null; + query.StartIndex = null; + + items = GetRecursiveChildren(user, query, out totalCount); + + // Restore pagination params so PostFilterAndSort can apply them after sorting + query.Limit = limit; + query.StartIndex = startIndex; } - return PostFilterAndSort(items, query, true); + return PostFilterAndSort(items, query); } if (this is not UserRootFolder @@ -980,25 +978,31 @@ namespace MediaBrowser.Controller.Entities IEnumerable<BaseItem> items; + int totalItemCount = 0; if (query.User is null) { items = Children.Where(filter); + totalItemCount = items.Count(); } else { // need to pass this param to the children. + // Note: Don't pass Limit/StartIndex here as pagination should happen after sorting in PostFilterAndSort var childQuery = new InternalItemsQuery { - DisplayAlbumFolders = query.DisplayAlbumFolders + DisplayAlbumFolders = query.DisplayAlbumFolders, + NameStartsWith = query.NameStartsWith, + NameStartsWithOrGreater = query.NameStartsWithOrGreater, + NameLessThan = query.NameLessThan }; - items = GetChildren(user, true, childQuery).Where(filter); + items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter); } - return PostFilterAndSort(items, query, true); + return PostFilterAndSort(items, query); } - protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query, bool enableSorting) + protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query) { var user = query.User; @@ -1008,7 +1012,7 @@ namespace MediaBrowser.Controller.Entities items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager); } - #pragma warning disable CA1309 +#pragma warning disable CA1309 if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater)) { items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1); @@ -1023,7 +1027,7 @@ namespace MediaBrowser.Controller.Entities { items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1); } - #pragma warning restore CA1309 +#pragma warning restore CA1309 // This must be the last filter if (!query.AdjacentTo.IsNullOrEmpty()) @@ -1031,7 +1035,15 @@ namespace MediaBrowser.Controller.Entities items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); } - return UserViewBuilder.SortAndPage(items, null, query, LibraryManager, enableSorting); + var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); + var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); + + if (query.EnableTotalRecordCount) + { + result.TotalRecordCount = filteredItems.Count; + } + + return result; } private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded( @@ -1044,12 +1056,49 @@ namespace MediaBrowser.Controller.Entities { ArgumentNullException.ThrowIfNull(items); - if (CollapseBoxSetItems(query, queryParent, user, configurationManager)) + if (!CollapseBoxSetItems(query, queryParent, user, configurationManager)) + { + return items; + } + + var config = configurationManager.Configuration; + + bool collapseMovies = config.EnableGroupingMoviesIntoCollections; + bool collapseSeries = config.EnableGroupingShowsIntoCollections; + + if (user is null || (collapseMovies && collapseSeries)) + { + return collectionManager.CollapseItemsWithinBoxSets(items, user); + } + + if (!collapseMovies && !collapseSeries) { - items = collectionManager.CollapseItemsWithinBoxSets(items, user); + return items; } - return items; + var collapsibleItems = new List<BaseItem>(); + var remainingItems = new List<BaseItem>(); + + foreach (var item in items) + { + if ((collapseMovies && item is Movie) || (collapseSeries && item is Series)) + { + collapsibleItems.Add(item); + } + else + { + remainingItems.Add(item); + } + } + + if (collapsibleItems.Count == 0) + { + return remainingItems; + } + + var collapsedItems = collectionManager.CollapseItemsWithinBoxSets(collapsibleItems, user); + + return collapsedItems.Concat(remainingItems); } private static bool CollapseBoxSetItems( @@ -1080,24 +1129,26 @@ namespace MediaBrowser.Controller.Entities } var param = query.CollapseBoxSetItems; - - if (!param.HasValue) + if (param.HasValue) { - if (user is not null && query.IncludeItemTypes.Any(type => - (type == BaseItemKind.Movie && !configurationManager.Configuration.EnableGroupingMoviesIntoCollections) || - (type == BaseItemKind.Series && !configurationManager.Configuration.EnableGroupingShowsIntoCollections))) - { - return false; - } + return param.Value && AllowBoxSetCollapsing(query); + } - if (query.IncludeItemTypes.Length == 0 - || query.IncludeItemTypes.Any(type => type == BaseItemKind.Movie || type == BaseItemKind.Series)) - { - param = true; - } + var config = configurationManager.Configuration; + + bool queryHasMovies = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Movie); + bool queryHasSeries = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Series); + + bool collapseMovies = config.EnableGroupingMoviesIntoCollections; + bool collapseSeries = config.EnableGroupingShowsIntoCollections; + + if (user is not null) + { + bool canCollapse = (queryHasMovies && collapseMovies) || (queryHasSeries && collapseSeries); + return canCollapse && AllowBoxSetCollapsing(query); } - return param.HasValue && param.Value && AllowBoxSetCollapsing(query); + return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query); } private static bool AllowBoxSetCollapsing(InternalItemsQuery request) @@ -1222,11 +1273,6 @@ namespace MediaBrowser.Controller.Entities return false; } - if (request.IsPlayed.HasValue) - { - return false; - } - if (!string.IsNullOrWhiteSpace(request.Person)) { return false; @@ -1267,17 +1313,15 @@ namespace MediaBrowser.Controller.Entities return false; } - if (request.MinCommunityRating.HasValue) - { - return false; - } - - if (request.MinCriticRating.HasValue) + if (request.MinIndexNumber.HasValue) { return false; } - if (request.MinIndexNumber.HasValue) + if (request.OrderBy.Any(o => + o.OrderBy == ItemSortBy.CommunityRating || + o.OrderBy == ItemSortBy.CriticRating || + o.OrderBy == ItemSortBy.Runtime)) { return false; } @@ -1285,30 +1329,30 @@ namespace MediaBrowser.Controller.Entities return true; } - public IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren) - { - ArgumentNullException.ThrowIfNull(user); - - return GetChildren(user, includeLinkedChildren, new InternalItemsQuery(user)); - } - - public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null) { ArgumentNullException.ThrowIfNull(user); + query ??= new InternalItemsQuery(); + query.User = user; // the true root should return our users root folder children if (IsPhysicalRoot) { - return LibraryManager.GetUserRootFolder().GetChildren(user, includeLinkedChildren); + return LibraryManager.GetUserRootFolder().GetChildren(user, includeLinkedChildren, out totalItemCount); } var result = new Dictionary<Guid, BaseItem>(); - AddChildren(user, includeLinkedChildren, result, false, query); + totalItemCount = AddChildren(user, includeLinkedChildren, result, false, query); return result.Values.ToArray(); } + public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query = null) + { + return GetChildren(user, includeLinkedChildren, out _, query); + } + protected virtual IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { return Children; @@ -1317,13 +1361,13 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Adds the children to list. /// </summary> - private void AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders = null) + private int AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders = null) { // Prevent infinite recursion of nested folders visitedFolders ??= new HashSet<Folder>(); if (!visitedFolders.Add(this)) { - return; + return 0; } // If Query.AlbumFolders is set, then enforce the format as per the db in that it permits sub-folders in music albums. @@ -1340,44 +1384,67 @@ namespace MediaBrowser.Controller.Entities children = GetEligibleChildrenForRecursiveChildren(user); } - AddChildrenFromCollection(children, user, includeLinkedChildren, result, recursive, query, visitedFolders); - if (includeLinkedChildren) { - AddChildrenFromCollection(GetLinkedChildren(user), user, includeLinkedChildren, result, recursive, query, visitedFolders); + children = children.Concat(GetLinkedChildren(user)).ToArray(); } + + return AddChildrenFromCollection(children, user, includeLinkedChildren, result, recursive, query, visitedFolders); } - private void AddChildrenFromCollection(IEnumerable<BaseItem> children, User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders) + private int AddChildrenFromCollection(IEnumerable<BaseItem> children, User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders) { - foreach (var child in children) + query ??= new InternalItemsQuery(); + var limit = query.Limit > 0 ? query.Limit : int.MaxValue; + query.Limit = 0; + + var visibleChildren = children + .Where(e => e.IsVisible(user)) + .ToArray(); + + var realChildren = visibleChildren + .Where(e => query is null || UserViewBuilder.FilterItem(e, query)) + .ToArray(); + + if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0)) { - if (!child.IsVisible(user)) - { - continue; - } + realChildren = realChildren + .OrderBy(e => e.ProductionYear ?? int.MaxValue) + .ToArray(); + } - if (query is null || UserViewBuilder.FilterItem(child, query)) + var childCount = realChildren.Length; + if (result.Count < limit) + { + var remainingCount = (int)(limit - result.Count); + foreach (var child in realChildren + .Skip(query.StartIndex ?? 0) + .Take(remainingCount)) { result[child.Id] = child; } + } - if (recursive && child.IsFolder) + if (recursive) + { + foreach (var child in visibleChildren + .Where(e => e.IsFolder) + .OfType<Folder>()) { - var folder = (Folder)child; - - folder.AddChildren(user, includeLinkedChildren, result, true, query, visitedFolders); + childCount += child.AddChildren(user, includeLinkedChildren, result, true, query, visitedFolders); } } + + return childCount; } - public virtual IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) + public virtual IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { ArgumentNullException.ThrowIfNull(user); var result = new Dictionary<Guid, BaseItem>(); - AddChildren(user, true, result, true, query); + totalCount = AddChildren(user, true, result, true, query); return result.Values.ToArray(); } @@ -1709,23 +1776,14 @@ namespace MediaBrowser.Controller.Entities } } - public override bool IsPlayed(User user) + public override bool IsPlayed(User user, UserItemData userItemData) { - var itemsResult = GetItemList(new InternalItemsQuery(user) - { - Recursive = true, - IsFolder = false, - IsVirtualItem = false, - EnableTotalRecordCount = false - }); - - return itemsResult - .All(i => i.IsPlayed(user)); + return ItemRepository.GetIsPlayed(user, Id, true); } - public override bool IsUnplayed(User user) + public override bool IsUnplayed(User user, UserItemData userItemData) { - return !IsPlayed(user); + return !IsPlayed(user, userItemData); } public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields) diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index d50f3d075..b32b64f5d 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Diacritics.Extensions; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; @@ -373,8 +374,15 @@ namespace MediaBrowser.Controller.Entities .Where(i => i != other) .Select(e => Enum.Parse<UnratedItem>(e, true)).ToArray(); - ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags); - IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags); + ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.RemoveDiacritics().ToLowerInvariant()) + .ToArray(); + + IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.RemoveDiacritics().ToLowerInvariant()) + .ToArray(); User = user; } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index d656fccb4..1d1fb2c39 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -136,9 +136,9 @@ namespace MediaBrowser.Controller.Entities.Movies return Sort(children, user).ToArray(); } - public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { - var children = base.GetRecursiveChildren(user, query); + var children = base.GetRecursiveChildren(user, query, out totalCount); return Sort(children, user).ToArray(); } @@ -197,7 +197,7 @@ namespace MediaBrowser.Controller.Entities.Movies var expandedFolders = new List<Guid>(); return FlattenItems(this, expandedFolders) - .SelectMany(i => LibraryManager.GetCollectionFolders(i)) + .SelectMany(LibraryManager.GetCollectionFolders) .Select(i => i.Id) .Distinct() .ToArray(); diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 46bad3f3b..6bdba36f9 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -7,12 +7,14 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.Json.Serialization; +using System.Threading; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities.TV @@ -22,6 +24,8 @@ namespace MediaBrowser.Controller.Entities.TV /// </summary> public class Episode : Video, IHasTrailers, IHasLookupInfo<EpisodeInfo>, IHasSeries { + public static IMediaEncoder MediaEncoder { get; set; } + /// <inheritdoc /> [JsonIgnore] public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() @@ -325,6 +329,39 @@ namespace MediaBrowser.Controller.Entities.TV { if (SourceType == SourceType.Library || SourceType == SourceType.LiveTV) { + var libraryOptions = LibraryManager.GetLibraryOptions(this); + if (libraryOptions.EnableEmbeddedEpisodeInfos && string.Equals(Container, "mp4", StringComparison.OrdinalIgnoreCase)) + { + try + { + var mediaInfo = MediaEncoder.GetMediaInfo( + new MediaInfoRequest + { + MediaSource = GetMediaSources(false)[0], + MediaType = DlnaProfileType.Video + }, + CancellationToken.None).GetAwaiter().GetResult(); + if (mediaInfo.ParentIndexNumber > 0) + { + ParentIndexNumber = mediaInfo.ParentIndexNumber; + } + + if (mediaInfo.IndexNumber > 0) + { + IndexNumber = mediaInfo.IndexNumber; + } + + if (!string.IsNullOrEmpty(mediaInfo.ShowName)) + { + SeriesName = mediaInfo.ShowName; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error reading the episode information with ffprobe. Episode: {EpisodeInfo}", Path); + } + } + try { if (LibraryManager.FillMissingEpisodeNumbersFromPath(this, replaceAllMetadata)) diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index 408161b03..b972ebaa6 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -123,7 +123,7 @@ namespace MediaBrowser.Controller.Entities.TV public override int GetChildCount(User user) { - var result = GetChildren(user, true).Count; + var result = GetChildren(user, true, null).Count; return result; } @@ -179,7 +179,7 @@ namespace MediaBrowser.Controller.Entities.TV var items = GetEpisodes(user, query.DtoOptions, true).Where(filter); - return PostFilterAndSort(items, query, false); + return PostFilterAndSort(items, query); } /// <summary> diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index b4ad05921..427c2995b 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; query.IncludeItemTypes = new[] { BaseItemKind.Season }; - query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; + query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) }; if (user is not null && !user.DisplayMissingEpisodes) { @@ -247,10 +247,6 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; - if (query.OrderBy.Count == 0) - { - query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; - } if (query.IncludeItemTypes.Length == 0) { @@ -301,6 +297,7 @@ namespace MediaBrowser.Controller.Entities.TV public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { + Children = null; // invalidate cached children. // Refresh bottom up, seasons and episodes first, then the series var items = GetRecursiveChildren(); diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs index bc7e22d9a..deed3631b 100644 --- a/MediaBrowser.Controller/Entities/UserRootFolder.cs +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -80,7 +80,7 @@ namespace MediaBrowser.Controller.Entities PresetViews = query.PresetViews }); - return UserViewBuilder.SortAndPage(result, null, query, LibraryManager, true); + return UserViewBuilder.SortAndPage(result, null, query, LibraryManager); } public override int GetChildCount(User user) diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs index dfa31315c..5624f8b2e 100644 --- a/MediaBrowser.Controller/Entities/UserView.cs +++ b/MediaBrowser.Controller/Entities/UserView.cs @@ -89,7 +89,7 @@ namespace MediaBrowser.Controller.Entities /// <inheritdoc /> public override int GetChildCount(User user) { - return GetChildren(user, true).Count; + return GetChildren(user, true, null).Count; } /// <inheritdoc /> @@ -134,20 +134,22 @@ namespace MediaBrowser.Controller.Entities } /// <inheritdoc /> - public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { query.SetUser(user); query.Recursive = true; query.EnableTotalRecordCount = false; query.ForceDirect = true; + var data = GetItemList(query); + totalCount = data.Count; - return GetItemList(query); + return data; } /// <inheritdoc /> protected override IReadOnlyList<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { - return GetChildren(user, false); + return GetChildren(user, false, null); } public static bool IsUserSpecific(Folder folder) diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index c2b4da32a..4f9e9261b 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -438,22 +438,18 @@ namespace MediaBrowser.Controller.Entities items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); } - return SortAndPage(items, totalRecordLimit, query, libraryManager, true); + return SortAndPage(items, totalRecordLimit, query, libraryManager); } public static QueryResult<BaseItem> SortAndPage( IEnumerable<BaseItem> items, int? totalRecordLimit, InternalItemsQuery query, - ILibraryManager libraryManager, - bool enableSorting) + ILibraryManager libraryManager) { - if (enableSorting) + if (query.OrderBy.Count > 0) { - if (query.OrderBy.Count > 0) - { - items = libraryManager.Sort(items, query.User, query.OrderBy); - } + items = libraryManager.Sort(items, query.User, query.OrderBy); } var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray(); @@ -476,6 +472,23 @@ namespace MediaBrowser.Controller.Entities public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager) { + if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)) + { + return false; + } + +#pragma warning disable CA1309 // Use ordinal string comparison + if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater) && string.Compare(query.NameStartsWithOrGreater, item.SortName, StringComparison.InvariantCultureIgnoreCase) == 1) + { + return false; + } + + if (!string.IsNullOrEmpty(query.NameLessThan) && string.Compare(query.NameLessThan, item.SortName, StringComparison.InvariantCultureIgnoreCase) != 1) +#pragma warning restore CA1309 // Use ordinal string comparison + { + return false; + } + if (query.MediaTypes.Length > 0 && !query.MediaTypes.Contains(item.MediaType)) { return false; @@ -506,7 +519,6 @@ namespace MediaBrowser.Controller.Entities if (query.IsLiked.HasValue) { userData = userDataManager.GetUserData(user, item); - if (!userData.Likes.HasValue || userData.Likes != query.IsLiked.Value) { return false; @@ -515,7 +527,7 @@ namespace MediaBrowser.Controller.Entities if (query.IsFavoriteOrLiked.HasValue) { - userData = userData ?? userDataManager.GetUserData(user, item); + userData ??= userDataManager.GetUserData(user, item); var isFavoriteOrLiked = userData.IsFavorite || (userData.Likes ?? false); if (isFavoriteOrLiked != query.IsFavoriteOrLiked.Value) @@ -526,8 +538,7 @@ namespace MediaBrowser.Controller.Entities if (query.IsFavorite.HasValue) { - userData = userData ?? userDataManager.GetUserData(user, item); - + userData ??= userDataManager.GetUserData(user, item); if (userData.IsFavorite != query.IsFavorite.Value) { return false; @@ -536,7 +547,7 @@ namespace MediaBrowser.Controller.Entities if (query.IsResumable.HasValue) { - userData = userData ?? userDataManager.GetUserData(user, item); + userData ??= userDataManager.GetUserData(user, item); var isResumable = userData.PlaybackPositionTicks > 0; if (isResumable != query.IsResumable.Value) @@ -547,7 +558,8 @@ namespace MediaBrowser.Controller.Entities if (query.IsPlayed.HasValue) { - if (item.IsPlayed(user) != query.IsPlayed.Value) + userData ??= userDataManager.GetUserData(user, item); + if (item.IsPlayed(user, userData) != query.IsPlayed.Value) { return false; } @@ -924,6 +936,11 @@ namespace MediaBrowser.Controller.Entities } } + if (query.ExcludeItemIds.Contains(item.Id)) + { + return false; + } + return true; } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 04f47b729..1043029c6 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -152,16 +152,7 @@ namespace MediaBrowser.Controller.Entities { get { - if (!string.IsNullOrEmpty(PrimaryVersionId)) - { - var item = LibraryManager.GetItemById(PrimaryVersionId); - if (item is Video video) - { - return video.MediaSourceCount; - } - } - - return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1; + return GetMediaSourceCount(); } } @@ -259,6 +250,27 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override MediaType MediaType => MediaType.Video; + private int GetMediaSourceCount(HashSet<Guid> callstack = null) + { + callstack ??= new(); + if (!string.IsNullOrEmpty(PrimaryVersionId)) + { + var item = LibraryManager.GetItemById(PrimaryVersionId); + if (item is Video video) + { + if (callstack.Contains(video.Id)) + { + return video.LinkedAlternateVersions.Length + video.LocalAlternateVersions.Length + 1; + } + + callstack.Add(video.Id); + return video.GetMediaSourceCount(callstack); + } + } + + return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1; + } + public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); diff --git a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs index 2742f21e3..b53210b0b 100644 --- a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs +++ b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs @@ -167,12 +167,12 @@ public static class XmlReaderExtensions // Only split by comma if there is no pipe in the string // We have to be careful to not split names like Matthew, Jr. - var separator = !value.Contains('|', StringComparison.Ordinal) + ReadOnlySpan<char> separator = !value.Contains('|', StringComparison.Ordinal) && !value.Contains(';', StringComparison.Ordinal) - ? new[] { ',' } - : new[] { '|', ';' }; + ? stackalloc[] { ',' } + : stackalloc[] { '|', ';' }; - foreach (var part in value.Trim().Trim(separator).Split(separator)) + foreach (var part in value.AsSpan().Trim().Trim(separator).ToString().Split(separator)) { if (!string.IsNullOrWhiteSpace(part)) { diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs index a97096eae..7e235ed26 100644 --- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs +++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs @@ -60,8 +60,15 @@ namespace MediaBrowser.Controller void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences); /// <summary> - /// Saves changes made to the database. + /// Updates or Creates the display preferences. /// </summary> - void SaveChanges(); + /// <param name="displayPreferences">The entity to update or create.</param> + void UpdateDisplayPreferences(DisplayPreferences displayPreferences); + + /// <summary> + /// Updates or Creates the display preferences for the given item. + /// </summary> + /// <param name="itemDisplayPreferences">The entity to update or create.</param> + void UpdateItemDisplayPreferences(ItemDisplayPreferences itemDisplayPreferences); } } diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs index 1a33c3aa8..3e390ca42 100644 --- a/MediaBrowser.Controller/IO/FileSystemHelper.cs +++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using MediaBrowser.Model.IO; @@ -61,4 +62,82 @@ public static class FileSystemHelper } } } + + /// <summary> + /// Gets the target of the specified file link. + /// </summary> + /// <remarks> + /// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128. + /// </remarks> + /// <param name="linkPath">The path of the file link.</param> + /// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param> + /// <returns> + /// A <see cref="FileInfo"/> if the <paramref name="linkPath"/> is a link, regardless of if the target exists; otherwise, <c>null</c>. + /// </returns> + public static FileInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget = false) + { + // Check if the file exists so the native resolve handler won't throw at us. + if (!File.Exists(linkPath)) + { + return null; + } + + if (!returnFinalTarget) + { + return File.ResolveLinkTarget(linkPath, returnFinalTarget: false) as FileInfo; + } + + if (File.ResolveLinkTarget(linkPath, returnFinalTarget: false) is not FileInfo targetInfo) + { + return null; + } + + if (!targetInfo.Exists) + { + return targetInfo; + } + + var currentPath = targetInfo.FullName; + var visited = new HashSet<string>(StringComparer.Ordinal) { linkPath, currentPath }; + while (File.ResolveLinkTarget(currentPath, returnFinalTarget: false) is FileInfo linkInfo) + { + var targetPath = linkInfo.FullName; + + // If an infinite loop is detected, return the file info for the + // first link in the loop we encountered. + if (!visited.Add(targetPath)) + { + return new FileInfo(targetPath); + } + + targetInfo = linkInfo; + currentPath = targetPath; + + // Exit if the target doesn't exist, so the native resolve handler won't throw at us. + if (!targetInfo.Exists) + { + break; + } + } + + return targetInfo; + } + + /// <summary> + /// Gets the target of the specified file link. + /// </summary> + /// <remarks> + /// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128. + /// </remarks> + /// <param name="fileInfo">The file info of the file link.</param> + /// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param> + /// <returns> + /// A <see cref="FileInfo"/> if the <paramref name="fileInfo"/> is a link, regardless of if the target exists; otherwise, <c>null</c>. + /// </returns> + public static FileInfo? ResolveLinkTarget(FileInfo fileInfo, bool returnFinalTarget = false) + { + ArgumentNullException.ThrowIfNull(fileInfo); + + return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget); + } } diff --git a/MediaBrowser.Controller/IO/IExternalDataManager.cs b/MediaBrowser.Controller/IO/IExternalDataManager.cs new file mode 100644 index 000000000..f69f4586c --- /dev/null +++ b/MediaBrowser.Controller/IO/IExternalDataManager.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.IO; + +/// <summary> +/// Interface IPathManager. +/// </summary> +public interface IExternalDataManager +{ + /// <summary> + /// Deletes all external item data. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/IO/IPathManager.cs b/MediaBrowser.Controller/IO/IPathManager.cs index 7c20164a6..eb6743754 100644 --- a/MediaBrowser.Controller/IO/IPathManager.cs +++ b/MediaBrowser.Controller/IO/IPathManager.cs @@ -1,10 +1,10 @@ +using System.Collections.Generic; using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Dto; namespace MediaBrowser.Controller.IO; /// <summary> -/// Interface ITrickplayManager. +/// Interface IPathManager. /// </summary> public interface IPathManager { @@ -46,4 +46,26 @@ public interface IPathManager /// <param name="mediaSourceId">The media source id.</param> /// <returns>The absolute path.</returns> public string GetAttachmentFolderPath(string mediaSourceId); + + /// <summary> + /// Gets the chapter images data path. + /// </summary> + /// <param name="item">The base item.</param> + /// <returns>The chapter images data path.</returns> + public string GetChapterImageFolderPath(BaseItem item); + + /// <summary> + /// Gets the chapter images path. + /// </summary> + /// <param name="item">The base item.</param> + /// <param name="chapterPositionTicks">The chapter position.</param> + /// <returns>The chapter images data path.</returns> + public string GetChapterImagePath(BaseItem item, long chapterPositionTicks); + + /// <summary> + /// Gets the paths of extracted data folders. + /// </summary> + /// <param name="item">The base item.</param> + /// <returns>The absolute paths.</returns> + public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item); } diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index e9c4d9e19..b76141db0 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -39,6 +39,11 @@ namespace MediaBrowser.Controller string FriendlyName { get; } /// <summary> + /// Gets or sets the path to the backup archive used to restore upon restart. + /// </summary> + string RestoreBackupPath { get; set; } + + /// <summary> /// Gets a URL specific for the request. /// </summary> /// <param name="request">The <see cref="HttpRequest"/> instance.</param> diff --git a/MediaBrowser.Controller/IServerApplicationPaths.cs b/MediaBrowser.Controller/IServerApplicationPaths.cs index 608286cd8..a6e83a02c 100644 --- a/MediaBrowser.Controller/IServerApplicationPaths.cs +++ b/MediaBrowser.Controller/IServerApplicationPaths.cs @@ -2,6 +2,10 @@ #pragma warning disable CS1591 +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using MediaBrowser.Common.Configuration; namespace MediaBrowser.Controller diff --git a/MediaBrowser.Controller/Library/IKeyframeManager.cs b/MediaBrowser.Controller/Library/IKeyframeManager.cs new file mode 100644 index 000000000..b0155efdd --- /dev/null +++ b/MediaBrowser.Controller/Library/IKeyframeManager.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.MediaEncoding.Keyframes; + +namespace MediaBrowser.Controller.IO; + +/// <summary> +/// Interface IKeyframeManager. +/// </summary> +public interface IKeyframeManager +{ + /// <summary> + /// Gets the keyframe data. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <returns>The keyframe data.</returns> + IReadOnlyList<KeyframeData> GetKeyframeData(Guid itemId); + + /// <summary> + /// Saves the keyframe data. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="data">The keyframe data.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The task object representing the asynchronous operation.</returns> + Task SaveKeyframeDataAsync(Guid itemId, KeyframeData data, CancellationToken cancellationToken); + + /// <summary> + /// Deletes the keyframe data. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The task object representing the asynchronous operation.</returns> + Task DeleteKeyframeDataAsync(Guid itemId, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index df90f546c..fcc5ed672 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -220,13 +220,13 @@ namespace MediaBrowser.Controller.Library /// <param name="resolvers">The resolvers.</param> /// <param name="introProviders">The intro providers.</param> /// <param name="itemComparers">The item comparers.</param> - /// <param name="postscanTasks">The postscan tasks.</param> + /// <param name="postScanTasks">The post scan tasks.</param> void AddParts( IEnumerable<IResolverIgnoreRule> rules, IEnumerable<IItemResolver> resolvers, IEnumerable<IIntroProvider> introProviders, IEnumerable<IBaseItemComparer> itemComparers, - IEnumerable<ILibraryPostScanTask> postscanTasks); + IEnumerable<ILibraryPostScanTask> postScanTasks); /// <summary> /// Sorts the specified items. @@ -337,6 +337,13 @@ namespace MediaBrowser.Controller.Library void DeleteItem(BaseItem item, DeleteOptions options); /// <summary> + /// Deletes items that are not having any children like Actors. + /// </summary> + /// <param name="items">Items to delete.</param> + /// <remarks>In comparison to <see cref="DeleteItem(BaseItem, DeleteOptions, BaseItem, bool)"/> this method skips a lot of steps assuming there are no children to recusively delete nor does it define the special handling for channels and alike.</remarks> + public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items); + + /// <summary> /// Deletes the item. /// </summary> /// <param name="item">Item to delete.</param> @@ -593,11 +600,11 @@ namespace MediaBrowser.Controller.Library QueryResult<BaseItem> GetItemsResult(InternalItemsQuery query); /// <summary> - /// Ignores the file. + /// Checks if the file is ignored. /// </summary> /// <param name="file">The file.</param> /// <param name="parent">The parent.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> + /// <returns><c>true</c> if ignored, <c>false</c> otherwise.</returns> bool IgnoreFile(FileSystemMetadata file, BaseItem parent); Guid GetStudioId(string name); @@ -624,12 +631,16 @@ namespace MediaBrowser.Controller.Library QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query); + IReadOnlyDictionary<string, MusicArtist[]> GetArtists(IReadOnlyList<string> names); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query); QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query); int GetCount(InternalItemsQuery query); + ItemCounts GetItemCounts(InternalItemsQuery query); + Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason); BaseItem GetParentItem(Guid? parentId, Guid? userId); diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs new file mode 100644 index 000000000..e7460a2e6 --- /dev/null +++ b/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.LibraryTaskScheduler; + +/// <summary> +/// Provides a shared scheduler to run library related tasks based on the <see cref="ServerConfiguration.LibraryScanFanoutConcurrency"/>. +/// </summary> +public interface ILimitedConcurrencyLibraryScheduler +{ + /// <summary> + /// Enqueues an action that will be invoked with the set data. + /// </summary> + /// <typeparam name="T">The data Type.</typeparam> + /// <param name="data">The data.</param> + /// <param name="worker">The callback to process the data.</param> + /// <param name="progress">A progress reporter.</param> + /// <param name="cancellationToken">Stop token.</param> + /// <returns>A task that finishes when all data has been processed by the worker.</returns> + Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs new file mode 100644 index 000000000..0de5f198d --- /dev/null +++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.LibraryTaskScheduler; + +/// <summary> +/// Provides Parallel action interface to process tasks with a set concurrency level. +/// </summary> +public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable +{ + private const int CleanupGracePeriod = 60; + private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly Dictionary<CancellationTokenSource, Task> _taskRunners = new(); + + private static readonly AsyncLocal<CancellationTokenSource> _deadlockDetector = new(); + + /// <summary> + /// Gets used to lock all operations on the Tasks queue and creating workers. + /// </summary> + private readonly Lock _taskLock = new(); + + private readonly BlockingCollection<TaskQueueItem> _tasks = new(); + + private volatile int _workCounter; + private Task? _cleanupTask; + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="LimitedConcurrencyLibraryScheduler"/> class. + /// </summary> + /// <param name="hostApplicationLifetime">The hosting lifetime.</param> + /// <param name="logger">The logger.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public LimitedConcurrencyLibraryScheduler( + IHostApplicationLifetime hostApplicationLifetime, + ILogger<LimitedConcurrencyLibraryScheduler> logger, + IServerConfigurationManager serverConfigurationManager) + { + _hostApplicationLifetime = hostApplicationLifetime; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + } + + private void ScheduleTaskCleanup() + { + lock (_taskLock) + { + if (_cleanupTask is not null) + { + _logger.LogDebug("Cleanup task already scheduled."); + // cleanup task is already running. + return; + } + + _cleanupTask = RunCleanupTask(); + } + + async Task RunCleanupTask() + { + _logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod); + await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false); + if (_disposed) + { + _logger.LogDebug("Abort cleaning up, already disposed."); + return; + } + + lock (_taskLock) + { + if (_tasks.Count > 0 || _workCounter > 0) + { + _logger.LogDebug("Delay cleanup task, operations still running."); + // tasks are still there so its still in use. Reschedule cleanup task. + // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended. + _cleanupTask = RunCleanupTask(); + return; + } + } + + _logger.LogDebug("Cleanup runners."); + foreach (var item in _taskRunners.ToArray()) + { + await item.Key.CancelAsync().ConfigureAwait(false); + _taskRunners.Remove(item.Key); + } + } + } + + private bool ShouldForceSequentialOperation() + { + // if the user either set the setting to 1 or it's unset and we have fewer than 4 cores it's better to run sequentially. + var fanoutSetting = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency; + return fanoutSetting == 1 || (fanoutSetting <= 0 && Environment.ProcessorCount <= 3); + } + + private int CalculateScanConcurrencyLimit() + { + // when this is invoked, we already checked ShouldForceSequentialOperation for the sequential check. + var fanoutConcurrency = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency; + if (fanoutConcurrency <= 0) + { + // in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by ShouldForceSequentialOperation. + return Environment.ProcessorCount - 3; + } + + return fanoutConcurrency; + } + + private void Worker() + { + lock (_taskLock) + { + var operationFanout = Math.Max(0, CalculateScanConcurrencyLimit() - _taskRunners.Count); + _logger.LogDebug("Spawn {NumberRunners} new runners.", operationFanout); + for (int i = 0; i < operationFanout; i++) + { + var stopToken = new CancellationTokenSource(); + var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping); + _taskRunners.Add( + combinedSource, + Task.Factory.StartNew( + ItemWorker, + (combinedSource, stopToken), + combinedSource.Token, + TaskCreationOptions.PreferFairness, + TaskScheduler.Default)); + } + } + } + + private async Task ItemWorker(object? obj) + { + var stopToken = ((CancellationTokenSource TaskStop, CancellationTokenSource GlobalStop))obj!; + _deadlockDetector.Value = stopToken.TaskStop; + try + { + foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token)) + { + stopToken.GlobalStop.Token.ThrowIfCancellationRequested(); + try + { + var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0; + Debug.Assert(newWorkerLimit, "_workCounter > 0"); + _logger.LogDebug("Process new item '{Data}'.", item.Data); + await ProcessItem(item).ConfigureAwait(false); + } + finally + { + var newWorkerLimit = Interlocked.Decrement(ref _workCounter) >= 0; + Debug.Assert(newWorkerLimit, "_workCounter > 0"); + } + } + } + catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested) + { + // thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose. + } + finally + { + _logger.LogDebug("Cleanup Runner'."); + _deadlockDetector.Value = default!; + _taskRunners.Remove(stopToken.TaskStop); + stopToken.GlobalStop.Dispose(); + stopToken.TaskStop.Dispose(); + } + } + + private async Task ProcessItem(TaskQueueItem item) + { + try + { + if (item.CancellationToken.IsCancellationRequested) + { + // if item is cancelled, just skip it + return; + } + + await item.Worker(item.Data).ConfigureAwait(true); + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error while performing a library operation"); + } + finally + { + item.Progress.Report(100); + item.Done.SetResult(); + } + } + + /// <inheritdoc/> + public async Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, CancellationToken cancellationToken) + { + if (_disposed) + { + return; + } + + if (data.Length == 0 || cancellationToken.IsCancellationRequested) + { + progress.Report(100); + return; + } + + _logger.LogDebug("Enqueue new Workset of {NoItems} items.", data.Length); + + TaskQueueItem[] workItems = null!; + + void UpdateProgress() + { + progress.Report(workItems.Select(e => e.ProgressValue).Average()); + } + + workItems = data.Select(item => + { + TaskQueueItem queueItem = null!; + return queueItem = new TaskQueueItem() + { + Data = item!, + Progress = new Progress<double>(innerPercent => + { + // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls + var innerPercentRounded = Math.Round(innerPercent); + if (queueItem.ProgressValue != innerPercentRounded) + { + queueItem.ProgressValue = innerPercentRounded; + UpdateProgress(); + } + }), + Worker = (val) => worker((T)val, queueItem.Progress), + CancellationToken = cancellationToken + }; + }).ToArray(); + + if (ShouldForceSequentialOperation()) + { + _logger.LogDebug("Process sequentially."); + try + { + foreach (var item in workItems) + { + await ProcessItem(item).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // operation is cancelled. Do nothing. + } + + _logger.LogDebug("Process sequentially done."); + return; + } + + for (var i = 0; i < workItems.Length; i++) + { + var item = workItems[i]!; + _tasks.Add(item, CancellationToken.None); + } + + if (_deadlockDetector.Value is not null) + { + _logger.LogDebug("Nested invocation detected, process in-place."); + try + { + // we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved + while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token)) + { + await ProcessItem(item).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested) + { + // operation is cancelled. Do nothing. + } + + _logger.LogDebug("process in-place done."); + } + else + { + Worker(); + _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length); + await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false); + _logger.LogDebug("{NoWorkers} completed.", workItems.Length); + ScheduleTaskCleanup(); + } + } + + /// <inheritdoc/> + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + _tasks.CompleteAdding(); + foreach (var item in _taskRunners) + { + await item.Key.CancelAsync().ConfigureAwait(false); + } + + _tasks.Dispose(); + if (_cleanupTask is not null) + { + await _cleanupTask.ConfigureAwait(false); + _cleanupTask?.Dispose(); + } + } + + private class TaskQueueItem + { + public required object Data { get; init; } + + public double ProgressValue { get; set; } + + public required Func<object, Task> Worker { get; init; } + + public required IProgress<double> Progress { get; init; } + + public TaskCompletionSource Done { get; } = new(); + + public CancellationToken CancellationToken { get; init; } + } +} diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 3353ad63f..b5d14e94b 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Controller</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> + <VersionPrefix>10.12.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 7c3138002..a1d891535 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -230,10 +230,10 @@ namespace MediaBrowser.Controller.MediaEncoding { var hwType = encodingOptions.HardwareAccelerationType; - // Only Intel has VA-API MJPEG encoder + // Only enable VA-API MJPEG encoder on Intel iHD driver. + // Legacy platforms supported ONLY by i965 do not support MJPEG encoder. if (hwType == HardwareAccelerationType.vaapi - && !(_mediaEncoder.IsVaapiDeviceInteliHD - || _mediaEncoder.IsVaapiDeviceInteli965)) + && !_mediaEncoder.IsVaapiDeviceInteliHD) { return _defaultMjpegEncoder; } @@ -1572,6 +1572,26 @@ namespace MediaBrowser.Controller.MediaEncoding return FormattableString.Invariant($" -maxrate {bitrate} -bufsize {bufsize}"); } + if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "av1_qsv", StringComparison.OrdinalIgnoreCase)) + { + // TODO: probe QSV encoders' capabilities and enable more tuning options + // See also https://github.com/intel/media-delivery/blob/master/doc/quality.rst + + // Enable MacroBlock level bitrate control for better subjective visual quality + var mbbrcOpt = string.Empty; + if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) + { + mbbrcOpt = " -mbbrc 1"; + } + + // Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation + // Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes + return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {bitrate + 1} -rc_init_occupancy {bitrate * 2} -bufsize {bitrate * 4}"); + } + if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase) || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase) || string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase)) @@ -2356,6 +2376,13 @@ namespace MediaBrowser.Controller.MediaEncoding 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); + var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase); + + // If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it. + if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI) + { + return false; + } if (!requestedRangeTypes.Contains(videoStream.VideoRangeType.ToString(), StringComparison.OrdinalIgnoreCase) && !((requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10) @@ -2363,6 +2390,12 @@ namespace MediaBrowser.Controller.MediaEncoding || (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR) || (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus))) { + // If the video stream is in HDR10+ or a static HDR format, don't allow copy if the client does not support HDR10 or HLG. + if (videoStream.VideoRangeType is VideoRangeType.HDR10Plus or VideoRangeType.HDR10 or VideoRangeType.HLG) + { + return false; + } + // Check complicated cases where we need to remove dynamic metadata // Conservatively refuse to copy if the encoder can't remove dynamic metadata, // but a removal is required for compatability reasons. @@ -3471,6 +3504,21 @@ namespace MediaBrowser.Controller.MediaEncoding doubleRateDeint ? "1" : "0"); } + if (hwDeintSuffix.Contains("opencl", StringComparison.OrdinalIgnoreCase)) + { + var useBwdif = options.DeinterlaceMethod == DeinterlaceMethod.bwdif; + + if (_mediaEncoder.SupportsFilter("yadif_opencl") + && _mediaEncoder.SupportsFilter("bwdif_opencl")) + { + return string.Format( + CultureInfo.InvariantCulture, + "{0}_opencl={1}:-1:0", + useBwdif ? "bwdif" : "yadif", + doubleRateDeint ? "1" : "0"); + } + } + if (hwDeintSuffix.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) { return string.Format( @@ -3947,6 +3995,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasSubs) { + var alphaFormatOpt = string.Empty; if (hasGraphicalSubs) { var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); @@ -3964,10 +4013,13 @@ namespace MediaBrowser.Controller.MediaEncoding subFilters.Add(alphaSrcFilter); subFilters.Add("format=yuva420p"); subFilters.Add(subTextSubtitlesFilter); + + alphaFormatOpt = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayCudaAlphaFormat) + ? ":alpha_format=premultiplied" : string.Empty; } subFilters.Add("hwupload=derive_device=cuda"); - overlayFilters.Add("overlay_cuda=eof_action=pass:repeatlast=0"); + overlayFilters.Add($"overlay_cuda=eof_action=pass:repeatlast=0{alphaFormatOpt}"); } } else @@ -4099,7 +4151,12 @@ namespace MediaBrowser.Controller.MediaEncoding // map from d3d11va to opencl via d3d11-opencl interop. mainFilters.Add("hwmap=derive_device=opencl:mode=read"); - // hw deint <= TODO: finish the 'yadif_opencl' filter + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "opencl"); + mainFilters.Add(deintFilter); + } // hw transpose if (doOclTranspose) @@ -4164,6 +4221,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasSubs) { + var alphaFormatOpt = string.Empty; if (hasGraphicalSubs) { var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); @@ -4181,10 +4239,13 @@ namespace MediaBrowser.Controller.MediaEncoding subFilters.Add(alphaSrcFilter); subFilters.Add("format=yuva420p"); subFilters.Add(subTextSubtitlesFilter); + + alphaFormatOpt = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayOpenclAlphaFormat) + ? ":alpha_format=premultiplied" : string.Empty; } subFilters.Add("hwupload=derive_device=opencl"); - overlayFilters.Add("overlay_opencl=eof_action=pass:repeatlast=0"); + overlayFilters.Add($"overlay_opencl=eof_action=pass:repeatlast=0{alphaFormatOpt}"); overlayFilters.Add("hwmap=derive_device=d3d11va:mode=write:reverse=1"); overlayFilters.Add("format=d3d11"); } @@ -4387,6 +4448,13 @@ namespace MediaBrowser.Controller.MediaEncoding var swapOutputWandH = doVppTranspose && swapWAndH; var hwScaleFilter = GetHwScaleFilter("vpp", "qsv", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + // d3d11va doesn't support dynamic pool size, use vpp filter ctx to relay + // to prevent encoder async and bframes from exhausting the decoder pool. + if (!string.IsNullOrEmpty(hwScaleFilter) && isD3d11vaDecoder) + { + hwScaleFilter += ":passthrough=0"; + } + if (!string.IsNullOrEmpty(hwScaleFilter) && doVppTranspose) { hwScaleFilter += $":transpose={transposeDir}"; @@ -5892,7 +5960,7 @@ namespace MediaBrowser.Controller.MediaEncoding // Use NV15 instead of P010 to avoid the issue. // SDR inputs are using BGRA formats already which is not affected. var intermediateFormat = string.Equals(outFormat, "p010", StringComparison.OrdinalIgnoreCase) ? "nv15" : outFormat; - var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_divisible_by=4:afbc=1"; + var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_original_aspect_ratio=increase:force_divisible_by=4:afbc=1"; mainFilters.Add(hwScaleFilterFirstPass); } @@ -5970,9 +6038,10 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasSubs) { + var subMaxH = 1080; if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, subMaxH); subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } @@ -5982,7 +6051,7 @@ namespace MediaBrowser.Controller.MediaEncoding var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10; // alphasrc=s=1280x720:r=10:start=0,format=bgra,subtitles,hwupload - var alphaSrcFilter = GetAlphaSrcFilter(state, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH, subFramerate); + var alphaSrcFilter = GetAlphaSrcFilter(state, swpInW, swpInH, reqW, reqH, reqMaxW, subMaxH, subFramerate); var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); subFilters.Add(alphaSrcFilter); subFilters.Add("format=bgra"); @@ -5991,6 +6060,13 @@ namespace MediaBrowser.Controller.MediaEncoding subFilters.Add("hwupload=derive_device=rkmpp"); + // offload 1080p+ subtitles swscale upscaling from CPU to RGA + var (overlayW, overlayH) = GetFixedOutputSize(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + if (overlayW.HasValue && overlayH.HasValue && overlayH.Value > subMaxH) + { + subFilters.Add($"vpp_rkrga=w={overlayW.Value}:h={overlayH.Value}:format=bgra:afbc=1"); + } + // try enabling AFBC to save DDR bandwidth var hwOverlayFilter = "overlay_rkrga=eof_action=pass:repeatlast=0:format=nv12"; if (isEncoderSupportAfbc) @@ -6476,7 +6552,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (isD3d11Supported && isCodecAvailable) { return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11 -noautorotate" + stripRotationDataArgs : string.Empty) - + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + " -threads 2" + (isAv1 ? " -c:v av1" : string.Empty); } } @@ -6947,7 +7023,8 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase)) { - return GetHwaccelType(state, options, "av1", bitDepth, hwSurface); + var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface); + return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty); } } @@ -7074,7 +7151,8 @@ namespace MediaBrowser.Controller.MediaEncoding inputModifier += " -async " + state.InputAudioSync; } - if (!string.IsNullOrEmpty(state.InputVideoSync)) + // The -fps_mode option cannot be applied to input + if (!string.IsNullOrEmpty(state.InputVideoSync) && _mediaEncoder.EncoderVersion < new Version(5, 1)) { inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion); } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 8d6211051..43680f5c0 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Globalization; using System.Linq; using Jellyfin.Data.Enums; @@ -22,8 +21,7 @@ namespace MediaBrowser.Controller.MediaEncoding // For now, a common base class until the API and MediaEncoding classes are unified public class EncodingJobInfo { - public int? OutputAudioBitrate; - public int? OutputAudioChannels; + private static readonly char[] _separators = ['|', ',']; private TranscodeReason? _transcodeReasons = null; @@ -36,6 +34,10 @@ namespace MediaBrowser.Controller.MediaEncoding SupportedSubtitleCodecs = Array.Empty<string>(); } + public int? OutputAudioBitrate { get; set; } + + public int? OutputAudioChannels { get; set; } + public TranscodeReason TranscodeReasons { get @@ -586,7 +588,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.Profile)) { - return BaseRequest.Profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -595,7 +597,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(profile)) { - return profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } @@ -606,7 +608,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType)) { - return BaseRequest.VideoRangeType.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -615,7 +617,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(rangetype)) { - return rangetype.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } @@ -626,7 +628,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.CodecTag)) { - return BaseRequest.CodecTag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.CodecTag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -635,7 +637,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(codectag)) { - return codectag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return codectag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } diff --git a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs index a2b6e1d73..6ad953023 100644 --- a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs +++ b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs @@ -38,6 +38,16 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// The transpose_opencl_reversal. /// </summary> - TransposeOpenclReversal = 6 + TransposeOpenclReversal = 6, + + /// <summary> + /// The overlay_opencl_alpha_format. + /// </summary> + OverlayOpenclAlphaFormat = 7, + + /// <summary> + /// The overlay_cuda_alpha_format. + /// </summary> + OverlayCudaAlphaFormat = 8 } } diff --git a/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs b/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs deleted file mode 100644 index 8ce40a58d..000000000 --- a/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs +++ /dev/null @@ -1,28 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Controller.MediaEncoding -{ - public interface IEncodingManager - { - /// <summary> - /// Refreshes the chapter images. - /// </summary> - /// <param name="video">Video to use.</param> - /// <param name="directoryService">Directory service to use.</param> - /// <param name="chapters">Set of chapters to refresh.</param> - /// <param name="extractImages">Option to extract images.</param> - /// <param name="saveChapters">Option to save chapters.</param> - /// <param name="cancellationToken">CancellationToken to use for operation.</param> - /// <returns><c>true</c> if successful, <c>false</c> if not.</returns> - Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken); - } -} diff --git a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs index 9bf27b3b2..bdd75da2f 100644 --- a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs @@ -53,5 +53,13 @@ namespace MediaBrowser.Controller.MediaEncoding /// <param name="cancellationToken">The cancellation token.</param> /// <returns>System.String.</returns> Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken); + + /// <summary> + /// Extracts all extractable subtitles (text and pgs). + /// </summary> + /// <param name="mediaSource">The mediaSource.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs index 456977b88..4f13a7ecc 100644 --- a/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs +++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs @@ -5,9 +5,10 @@ using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.MediaSegments; -namespace MediaBrowser.Controller; +namespace MediaBrowser.Controller.MediaSegments; /// <summary> /// Defines methods for interacting with media segments. @@ -18,10 +19,11 @@ public interface IMediaSegmentManager /// Uses all segment providers enabled for the <see cref="BaseItem"/>'s library to get the Media Segments. /// </summary> /// <param name="baseItem">The Item to evaluate.</param> - /// <param name="overwrite">If set, will remove existing segments and replace it with new ones otherwise will check for existing segments and if found any, stops.</param> - /// <param name="cancellationToken">stop request token.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="forceOverwrite">If set, will force to remove existing segments and replace it with new ones otherwise will check for existing segments and if found any that should not be deleted, stops.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>A task that indicates the Operation is finished.</returns> - Task RunSegmentPluginProviders(BaseItem baseItem, bool overwrite, CancellationToken cancellationToken); + Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken); /// <summary> /// Returns if this item supports media segments. @@ -46,22 +48,22 @@ public interface IMediaSegmentManager Task DeleteSegmentAsync(Guid segmentId); /// <summary> - /// Obtains all segments associated with the itemId. + /// Deletes all media segments of an item. /// </summary> - /// <param name="itemId">The id of the <see cref="BaseItem"/>.</param> - /// <param name="typeFilter">filters all media segments of the given type to be included. If null all types are included.</param> - /// <param name="filterByProvider">When set filters the segments to only return those that which providers are currently enabled on their library.</param> - /// <returns>An enumerator of <see cref="MediaSegmentDto"/>'s.</returns> - Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(Guid itemId, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true); + /// <param name="itemId">The <see cref="BaseItem.Id"/> to delete all segments for.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>a task.</returns> + Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken); /// <summary> /// Obtains all segments associated with the itemId. /// </summary> /// <param name="item">The <see cref="BaseItem"/>.</param> /// <param name="typeFilter">filters all media segments of the given type to be included. If null all types are included.</param> + /// <param name="libraryOptions">The library options.</param> /// <param name="filterByProvider">When set filters the segments to only return those that which providers are currently enabled on their library.</param> /// <returns>An enumerator of <see cref="MediaSegmentDto"/>'s.</returns> - Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem item, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true); + Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem item, IEnumerable<MediaSegmentType>? typeFilter, LibraryOptions libraryOptions, bool filterByProvider = true); /// <summary> /// Gets information about any media segments stored for the given itemId. diff --git a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs index 39bb58bef..5a6d15d78 100644 --- a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs +++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections; -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Model; using MediaBrowser.Model.MediaSegments; -namespace MediaBrowser.Controller; +namespace MediaBrowser.Controller.MediaSegments; /// <summary> /// Provides methods for Obtaining the Media Segments from an Item. diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 4757bfa30..1e0d77fe5 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -81,6 +81,16 @@ namespace MediaBrowser.Controller.Net protected abstract Task<TReturnDataType> GetDataToSend(); /// <summary> + /// Gets the data to send for a specific connection. + /// </summary> + /// <param name="connection">The connection.</param> + /// <returns>Task{`1}.</returns> + protected virtual Task<TReturnDataType> GetDataToSendForConnection(IWebSocketConnection connection) + { + return GetDataToSend(); + } + + /// <summary> /// Processes the message. /// </summary> /// <param name="message">The message.</param> @@ -174,17 +184,11 @@ namespace MediaBrowser.Controller.Net continue; } - var data = await GetDataToSend().ConfigureAwait(false); - if (data is null) - { - continue; - } - IEnumerable<Task> GetTasks() { foreach (var tuple in tuples) { - yield return SendDataInternal(data, tuple); + yield return SendDataForConnectionAsync(tuple); } } @@ -198,12 +202,19 @@ namespace MediaBrowser.Controller.Net } } - private async Task SendDataInternal(TReturnDataType data, (IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State) tuple) + private async Task SendDataForConnectionAsync((IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State) tuple) { try { var (connection, cts, state) = tuple; var cancellationToken = cts.Token; + + var data = await GetDataToSendForConnection(connection).ConfigureAwait(false); + if (data is null) + { + return; + } + await connection.SendAsync( new OutboundWebSocketMessage<TReturnDataType> { MessageType = Type, Data = data }, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Controller/Chapters/IChapterRepository.cs b/MediaBrowser.Controller/Persistence/IChapterRepository.cs index e22cb0f58..64b90fd63 100644 --- a/MediaBrowser.Controller/Chapters/IChapterRepository.cs +++ b/MediaBrowser.Controller/Persistence/IChapterRepository.cs @@ -1,36 +1,30 @@ using System; using System.Collections.Generic; -using MediaBrowser.Model.Dto; +using System.Threading; +using System.Threading.Tasks; using MediaBrowser.Model.Entities; -namespace MediaBrowser.Controller.Chapters; +namespace MediaBrowser.Controller.Persistence; /// <summary> -/// Interface IChapterManager. +/// Interface IChapterRepository. /// </summary> public interface IChapterRepository { /// <summary> - /// Saves the chapters. + /// Deletes the chapters. /// </summary> /// <param name="itemId">The item.</param> - /// <param name="chapters">The set of chapters.</param> - void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters); - - /// <summary> - /// Gets all chapters associated with the baseItem. - /// </summary> - /// <param name="baseItem">The baseitem.</param> - /// <returns>A readonly list of chapter instances.</returns> - IReadOnlyList<ChapterInfo> GetChapters(BaseItemDto baseItem); + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken); /// <summary> - /// Gets a single chapter of a BaseItem on a specific index. + /// Saves the chapters. /// </summary> - /// <param name="baseItem">The baseitem.</param> - /// <param name="index">The index of that chapter.</param> - /// <returns>A chapter instance.</returns> - ChapterInfo? GetChapter(BaseItemDto baseItem, int index); + /// <param name="itemId">The item.</param> + /// <param name="chapters">The set of chapters.</param> + void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters); /// <summary> /// Gets all chapters associated with the baseItem. diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index e185898bf..0026ab2b5 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -5,8 +5,11 @@ using System; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; @@ -20,8 +23,8 @@ public interface IItemRepository /// <summary> /// Deletes the item. /// </summary> - /// <param name="id">The identifier.</param> - void DeleteItem(Guid id); + /// <param name="ids">The identifier to delete.</param> + void DeleteItem(params IReadOnlyList<Guid> ids); /// <summary> /// Saves the items. @@ -83,6 +86,8 @@ public interface IItemRepository int GetCount(InternalItemsQuery filter); + ItemCounts GetItemCounts(InternalItemsQuery filter); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter); QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter); @@ -102,4 +107,27 @@ public interface IItemRepository IReadOnlyList<string> GetGenreNames(); IReadOnlyList<string> GetAllArtistNames(); + + /// <summary> + /// Checks if an item has been persisted to the database. + /// </summary> + /// <param name="id">The id to check.</param> + /// <returns>True if the item exists, otherwise false.</returns> + Task<bool> ItemExistsAsync(Guid id); + + /// <summary> + /// Gets a value indicating wherever all children of the requested Id has been played. + /// </summary> + /// <param name="user">The userdata to check against.</param> + /// <param name="id">The Top id to check.</param> + /// <param name="recursive">Whever the check should be done recursive. Warning expensive operation.</param> + /// <returns>A value indicating whever all children has been played.</returns> + bool GetIsPlayed(User user, Guid id, bool recursive); + + /// <summary> + /// Gets all artist matches from the db. + /// </summary> + /// <param name="artistNames">The names of the artists.</param> + /// <returns>A map of the artist name and the potential matches.</returns> + IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames); } diff --git a/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs b/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs index 4930434a7..2596784ba 100644 --- a/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs +++ b/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs @@ -26,4 +26,12 @@ public interface IKeyframeRepository /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The task object representing the asynchronous operation.</returns> Task SaveKeyframeDataAsync(Guid itemId, KeyframeData data, CancellationToken cancellationToken); + + /// <summary> + /// Deletes the keyframe data. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The task object representing the asynchronous operation.</returns> + Task DeleteKeyframeDataAsync(Guid itemId, CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index 1062399e3..fc367b829 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -149,9 +149,11 @@ namespace MediaBrowser.Controller.Playlists return []; } - public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { - return GetPlayableItems(user, query); + var items = GetPlayableItems(user, query); + totalCount = items.Count; + return items; } public IReadOnlyList<Tuple<LinkedChild, BaseItem>> GetManageableItems() diff --git a/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs b/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs index 97f653edf..2206a021a 100644 --- a/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs +++ b/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs @@ -26,6 +26,6 @@ namespace MediaBrowser.Controller.Sorting /// Gets or sets the user data repository. /// </summary> /// <value>The user data repository.</value> - IUserDataManager UserDataRepository { get; set; } + IUserDataManager UserDataManager { get; set; } } } diff --git a/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs new file mode 100644 index 000000000..b094ec275 --- /dev/null +++ b/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs @@ -0,0 +1,34 @@ +using System; + +namespace MediaBrowser.Controller.SystemBackupService; + +/// <summary> +/// Manifest type for backups internal structure. +/// </summary> +public class BackupManifestDto +{ + /// <summary> + /// Gets or sets the jellyfin version this backup was created with. + /// </summary> + public required Version ServerVersion { get; set; } + + /// <summary> + /// Gets or sets the backup engine version this backup was created with. + /// </summary> + public required Version BackupEngineVersion { get; set; } + + /// <summary> + /// Gets or sets the date this backup was created with. + /// </summary> + public required DateTimeOffset DateCreated { get; set; } + + /// <summary> + /// Gets or sets the path to the backup on the system. + /// </summary> + public required string Path { get; set; } + + /// <summary> + /// Gets or sets the contents of the backup archive. + /// </summary> + public required BackupOptionsDto Options { get; set; } +} diff --git a/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs new file mode 100644 index 000000000..fc5a109f1 --- /dev/null +++ b/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs @@ -0,0 +1,29 @@ +using System; + +namespace MediaBrowser.Controller.SystemBackupService; + +/// <summary> +/// Defines the optional contents of the backup archive. +/// </summary> +public class BackupOptionsDto +{ + /// <summary> + /// Gets or sets a value indicating whether the archive contains the Metadata contents. + /// </summary> + public bool Metadata { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the archive contains the Trickplay contents. + /// </summary> + public bool Trickplay { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the archive contains the Subtitle contents. + /// </summary> + public bool Subtitles { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the archive contains the Database contents. + /// </summary> + public bool Database { get; set; } = true; +} diff --git a/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs new file mode 100644 index 000000000..263fa00c8 --- /dev/null +++ b/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs @@ -0,0 +1,15 @@ +using System; +using MediaBrowser.Common.Configuration; + +namespace MediaBrowser.Controller.SystemBackupService; + +/// <summary> +/// Defines properties used to start a restore process. +/// </summary> +public class BackupRestoreRequestDto +{ + /// <summary> + /// Gets or Sets the name of the backup archive to restore from. Must be present in <see cref="IApplicationPaths.BackupPath"/>. + /// </summary> + public required string ArchiveFileName { get; set; } +} diff --git a/MediaBrowser.Controller/SystemBackupService/IBackupService.cs b/MediaBrowser.Controller/SystemBackupService/IBackupService.cs new file mode 100644 index 000000000..0c586d811 --- /dev/null +++ b/MediaBrowser.Controller/SystemBackupService/IBackupService.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using MediaBrowser.Controller.SystemBackupService; + +namespace Jellyfin.Server.Implementations.SystemBackupService; + +/// <summary> +/// Defines an interface to restore and backup the jellyfin system. +/// </summary> +public interface IBackupService +{ + /// <summary> + /// Creates a new Backup zip file containing the current state of the application. + /// </summary> + /// <param name="backupOptions">The backup options.</param> + /// <returns>A task.</returns> + Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions); + + /// <summary> + /// Gets a list of backups that are available to be restored from. + /// </summary> + /// <returns>A list of backup paths.</returns> + Task<BackupManifestDto[]> EnumerateBackups(); + + /// <summary> + /// Gets a single backup manifest if the path defines a valid Jellyfin backup archive. + /// </summary> + /// <param name="archivePath">The path to be loaded.</param> + /// <returns>The containing backup manifest or null if not existing or compatiable.</returns> + Task<BackupManifestDto?> GetBackupManifest(string archivePath); + + /// <summary> + /// Restores an backup zip file created by jellyfin. + /// </summary> + /// <param name="archivePath">Path to the archive.</param> + /// <returns>A Task.</returns> + /// <exception cref="FileNotFoundException">Thrown when an invalid or missing file is specified.</exception> + /// <exception cref="NotSupportedException">Thrown when attempt to load an unsupported backup is made.</exception> + /// <exception cref="InvalidOperationException">Thrown for errors during the restore.</exception> + Task RestoreBackupAsync(string archivePath); + + /// <summary> + /// Schedules a Restore and restarts the server. + /// </summary> + /// <param name="archivePath">The path to the archive to restore from.</param> + void ScheduleRestoreAndRestartServer(string archivePath); +} diff --git a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs index 9ac8ead11..fba24329a 100644 --- a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs +++ b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs @@ -21,7 +21,7 @@ public interface ITrickplayManager /// <param name="libraryOptions">The library options.</param> /// <param name="cancellationToken">CancellationToken to use for operation.</param> /// <returns>Task.</returns> - Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken); + Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken); /// <summary> /// Creates trickplay tiles out of individual thumbnails. @@ -59,6 +59,14 @@ public interface ITrickplayManager Task SaveTrickplayInfo(TrickplayInfo info); /// <summary> + /// Deletes all trickplay info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken); + + /// <summary> /// Gets all trickplay infos for all media streams of an item. /// </summary> /// <param name="item">The item.</param> @@ -93,7 +101,7 @@ public interface ITrickplayManager /// <param name="libraryOptions">The library options.</param> /// <param name="cancellationToken">CancellationToken to use for operation.</param> /// <returns>Task.</returns> - Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken); + Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken cancellationToken); /// <summary> /// Gets the trickplay HLS playlist. |
