diff options
Diffstat (limited to 'MediaBrowser.Controller')
53 files changed, 1349 insertions, 290 deletions
diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs index 14dc64dabd..36f0d2195c 100644 --- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs +++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using Jellyfin.Extensions; namespace MediaBrowser.Controller.ClientEvent { @@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent /// <inheritdoc /> public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) { - var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; + var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client"; + var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version"; + var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); + if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath)) + { + throw new ArgumentException("Path resolved to filename not in log directory"); + } + var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); await using (fileStream.ConfigureAwait(false)) { diff --git a/MediaBrowser.Controller/Drawing/ImageHelper.cs b/MediaBrowser.Controller/Drawing/ImageHelper.cs index 9ef92bc981..6f26b7d912 100644 --- a/MediaBrowser.Controller/Drawing/ImageHelper.cs +++ b/MediaBrowser.Controller/Drawing/ImageHelper.cs @@ -11,7 +11,9 @@ namespace MediaBrowser.Controller.Drawing // Determine the output size based on incoming parameters var newSize = DrawingUtils.Resize(originalImageSize, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0); newSize = DrawingUtils.ResizeFill(newSize, options.FillWidth, options.FillHeight); - return newSize; + + // Never encode larger than the source. + return DrawingUtils.ScaleDownToFit(newSize, originalImageSize); } } } diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index 052626355f..4259b7b65c 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -47,6 +47,20 @@ namespace MediaBrowser.Controller.Dto } /// <summary> + /// Gets options that populate nothing beyond the item's own stored columns. + /// </summary> + /// <remarks> + /// Each enabled field group is a collection the item query left-joins, so the rows returned are + /// the product of the item's provider, image and user data counts. Never use this for items that + /// get saved back: saving rewrites the owned rows from what the instance holds. + /// </remarks> + public static DtoOptions StoredColumnsOnly => new(false) + { + EnableImages = false, + EnableUserData = false + }; + + /// <summary> /// Gets or sets the fields to populate on the DTO. /// </summary> public IReadOnlyList<ItemFields> Fields { get; set; } diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs index a02802f41e..ef24f632ed 100644 --- a/MediaBrowser.Controller/Entities/AggregateFolder.cs +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -54,6 +54,24 @@ namespace MediaBrowser.Controller.Entities public string[] PhysicalLocationsList { get; set; } + // Children caches the resolved items, _childrenIds the ids they were loaded from. Clearing + // only the former sends the next read back through LoadChildren, which replays the stale + // id list, so a caller invalidating this folder has to drop both. + [JsonIgnore] + public override IEnumerable<BaseItem> Children + { + get => base.Children; + set + { + if (value is null) + { + ClearCache(); + } + + base.Children = value; + } + } + public override bool CanDelete() { return false; diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index d016d8f62b..281a98dad5 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -165,6 +165,18 @@ namespace MediaBrowser.Controller.Entities.Audio public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { + try + { + await RefreshAllMetadataInternal(refreshOptions, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + ReleaseCachedChildren(); + } + } + + private async Task RefreshAllMetadataInternal(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) + { var items = GetRecursiveChildren(); var totalItems = items.Count; diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index c25694aba5..1e2d94d2a4 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -173,10 +173,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.ArtistsPath, validName); } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index 65669e6804..23b3341dbc 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -80,10 +80,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.MusicGenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 49a4ed4bf6..70e7da8932 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -47,6 +48,10 @@ namespace MediaBrowser.Controller.Entities public const string ThemeSongFileName = "theme"; + // Well below the 255 byte limit of the common Linux filesystems and the 255 character limit + // of Windows, so the files inside the folder still fit within MAX_PATH. + private const int MaxItemByNameFolderNameBytes = 128; + /// <summary> /// The supported image extensions. /// </summary> @@ -87,7 +92,7 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; - private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private protected static readonly char[] VersionDelimiters = ['-', '_', '.']; private string _sortName; @@ -540,8 +545,8 @@ namespace MediaBrowser.Controller.Entities { if (!string.IsNullOrEmpty(ForcedSortName)) { - // Need the ToLower because that's what CreateSortName does - _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant(); + // Run the forced sort name through the same cleaning as auto-generated sort names. + _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration); } else { @@ -770,6 +775,17 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol; + /// <summary> + /// Gets a value indicating whether this item searches the folder it lives in for its own extras. + /// </summary> + [JsonIgnore] + protected virtual bool SearchesContainingFolderForExtras => + IsFileProtocol + && SupportsOwnedItems + && !IsInMixedFolder + && this is not (ICollectionFolder or UserRootFolder or AggregateFolder) + && GetType() != typeof(Folder); + [JsonIgnore] public virtual bool SupportsPeople => false; @@ -926,19 +942,68 @@ namespace MediaBrowser.Controller.Entities /// <returns>System.String.</returns> protected virtual string CreateSortName() { - if (Name is null) + return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration); + } + + /// <summary> + /// Turns an item-by-name entity's name into a folder name every supported filesystem accepts. + /// </summary> + /// <param name="name">The entity's name.</param> + /// <returns>The folder name.</returns> + public static string GetItemByNameFolderName(string name) + { + // Trim the period at the end because windows will have a hard time with that + var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.'); + + // Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be + // turned into a folder at all - and an entity with no folder can never be created, which + // leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan. + // Only broken provider data gets this long, but it still has to resolve to something, so + // keep a readable prefix and let a hash of the whole name tell two of them apart. + if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes) + { + return validName; + } + + var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var budget = MaxItemByNameFolderNameBytes - suffix.Length; + var length = Math.Min(validName.Length, budget); + while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget) + { + length--; + } + + // Never cut a surrogate pair in half, the lone half is not a valid file name character. + if (length > 0 && char.IsHighSurrogate(validName[length - 1])) + { + length--; + } + + return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix); + } + + /// <summary> + /// Cleans a raw name into its sortable form by applying the configured sort rules. + /// </summary> + /// <param name="name">The raw name to clean.</param> + /// <param name="enableAlphaNumericSorting">Whether alphanumeric sorting rules should be applied.</param> + /// <param name="configuration">The server configuration providing the sort rules.</param> + /// <returns>The cleaned, sortable name, or <c>null</c> if <paramref name="name"/> is <c>null</c>.</returns> + public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration) + { + if (name is null) { return null; // some items may not have name filled in properly } - if (!EnableAlphaNumericSorting) + if (!enableAlphaNumericSorting) { - return Name.TrimStart(); + return name.TrimStart(); } - var sortable = Name.Trim().ToLowerInvariant(); + var sortable = name.Trim().ToLowerInvariant(); - foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) + foreach (var search in configuration.SortRemoveWords) { // Remove from beginning if a space follows if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) @@ -956,12 +1021,12 @@ namespace MediaBrowser.Controller.Entities } } - foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) + foreach (var removeChar in configuration.SortRemoveCharacters) { sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); } - foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) + foreach (var replaceChar in configuration.SortReplaceCharacters) { sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); } @@ -1346,7 +1411,8 @@ namespace MediaBrowser.Controller.Entities /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is /// kept in the label, falling back to a space only when no structural delimiter is shared. The /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming - /// VideoFlagDelimiters). + /// VideoFlagDelimiters), except that a dot between digits is a decimal point rather than a + /// delimiter, so numeric version labels stay whole. /// </summary> /// <param name="fileNames">The version file names without extension; must contain at least one entry.</param> /// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns> @@ -1380,9 +1446,12 @@ namespace MediaBrowser.Controller.Entities if (!prefixIsWholeName) { - // Retreat to the last structural delimiter ('-', '_', '.'). + // Retreat to the last structural delimiter ('-', '_', '.'), skipping dots that are + // decimal points within a number rather than delimiters (see IsDecimalPoint). var cut = prefix.Length; - while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0) + while (cut > 0 + && (Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0 + || IsDecimalPoint(prefix, cut - 1, fileNames))) { cut--; } @@ -1402,6 +1471,31 @@ namespace MediaBrowser.Controller.Entities return prefix; } + private static bool IsDecimalPoint(string prefix, int index, IReadOnlyList<string> fileNames) + { + if (index == 0 || prefix[index] != '.' || !char.IsDigit(prefix[index - 1])) + { + return false; + } + + if (index + 1 < prefix.Length) + { + return char.IsDigit(prefix[index + 1]); + } + + // The dot ends the prefix, so the character after it is the first one that differs between + // the versions: only a decimal point when every version continues the number. + for (var i = 0; i < fileNames.Count; i++) + { + if (fileNames[i].Length <= index + 1 || !char.IsDigit(fileNames[i][index + 1])) + { + return false; + } + } + + return true; + } + public Task RefreshMetadata(CancellationToken cancellationToken) { return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken); @@ -1515,7 +1609,14 @@ namespace MediaBrowser.Controller.Entities /// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns> protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder)) + if (!SearchesContainingFolderForExtras) + { + return false; + } + + if (GetParent() is Folder container + && container.SearchesContainingFolderForExtras + && string.Equals(container.Path, ContainingFolderPath, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1530,33 +1631,59 @@ namespace MediaBrowser.Controller.Entities private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { + // An extra is owned by the version it is named after, so all of them are maintained together. + var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery() + { + OwnerIds = item.GetOwnedVersionIds() + }).Where(e => e.ExtraType.HasValue).ToList(); + + var currentExtraIds = currentExtras.Select(e => e.Id).ToArray(); + + // Snapshot the persisted names before resolving, as FindExtras corrects the name on the + // items it hands back and may well hand back these very instances. + var currentExtraNames = new Dictionary<Guid, string>(); + foreach (var extra in currentExtras) + { + currentExtraNames[extra.Id] = extra.Name; + } + var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); var newExtraIds = Array.ConvertAll(extras, x => x.Id); - var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery() - { - OwnerIds = [item.Id] - }).Select(e => e.Id).ToArray(); + var renamedExtraIds = extras + .Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal)) + .Select(e => e.Id) + .ToHashSet(); var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x)); - if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) + if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { + // The owner's dates may only have become known after its extras were created, so keep + // them in sync even when there is nothing to refresh. + foreach (var extra in currentExtras) + { + if (extra.ExtraType is not null && InheritDatesFromOwner(item, extra)) + { + await extra.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + return false; } - var ownerId = item.Id; - var tasks = extras.Select(i => { + var ownerId = item.GetOwnerIdForExtra(i); var subOptions = new MetadataRefreshOptions(options); - if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) + if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id)) { subOptions.ForceSave = true; } i.OwnerId = ownerId; i.ParentId = Guid.Empty; + return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); }); @@ -2639,6 +2766,32 @@ namespace MediaBrowser.Controller.Entities } } + /// <summary> + /// Applies the owner's premiere date and production year to an owned item, returning whether anything changed. + /// </summary> + /// <param name="owner">The owner.</param> + /// <param name="ownedItem">The owned item.</param> + /// <returns><c>true</c> if the owned item was changed, else <c>false</c>.</returns> + internal static bool InheritDatesFromOwner(BaseItem owner, BaseItem ownedItem) + { + // Extras have no release date of their own, so the owner's is authoritative. + var changed = false; + + if (owner.ProductionYear is not null && ownedItem.ProductionYear != owner.ProductionYear) + { + ownedItem.ProductionYear = owner.ProductionYear; + changed = true; + } + + if (owner.PremiereDate is not null && ownedItem.PremiereDate != owner.PremiereDate) + { + ownedItem.PremiereDate = owner.PremiereDate; + changed = true; + } + + return changed; + } + protected async Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken) { var newOptions = new MetadataRefreshOptions(options) @@ -2698,6 +2851,11 @@ namespace MediaBrowser.Controller.Entities ownedItem.CustomRating = item.CustomRating; newOptions.ForceSave = true; } + + if (InheritDatesFromOwner(item, ownedItem)) + { + newOptions.ForceSave = true; + } } await ownedItem.RefreshMetadata(newOptions, cancellationToken).ConfigureAwait(false); @@ -2864,6 +3022,25 @@ namespace MediaBrowser.Controller.Entities } /// <summary> + /// Gets the ids of this item and the versions of it whose extras it maintains. + /// </summary> + /// <returns>An array containing the version ids.</returns> + protected virtual Guid[] GetOwnedVersionIds() + { + return [Id]; + } + + /// <summary> + /// Gets the id of the version an extra belongs to. + /// </summary> + /// <param name="extra">The extra.</param> + /// <returns>The id of the owning version.</returns> + protected virtual Guid GetOwnerIdForExtra(BaseItem extra) + { + return Id; + } + + /// <summary> /// Get all extras associated with this item, sorted by <see cref="SortName"/>. /// </summary> /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs index 5187669373..8559681bdc 100644 --- a/MediaBrowser.Controller/Entities/Book.cs +++ b/MediaBrowser.Controller/Entities/Book.cs @@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities [Common.RequiresSourceSerialisation] public class Book : BaseItem, IHasLookupInfo<BookInfo>, IHasSeries { - public Book() - { - this.RunTimeTicks = TimeSpan.TicksPerSecond; - } - [JsonIgnore] public override MediaType MediaType => MediaType.Book; diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index b1f7f29bad..626bc0d5a1 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities public class Folder : BaseItem { private IEnumerable<BaseItem> _children; - - public Folder() - { - LinkedChildren = Array.Empty<LinkedChild>(); - } + private LinkedChild[] _linkedChildren = []; public static IUserViewManager UserViewManager { get; set; } @@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the linked children. /// </summary> [JsonIgnore] - public LinkedChild[] LinkedChildren { get; set; } + public LinkedChild[] LinkedChildren + { + get => _linkedChildren; + set + { + _linkedChildren = value; + + // Assigning the collection means the caller knows the complete set of links. + LinkedChildrenLoaded = true; + } + } + + /// <summary> + /// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links. + /// </summary> + /// <remarks> + /// An unloaded instance carries an empty array that means "unknown", not "no children" — + /// persisting it would delete every link the item has. + /// </remarks> + [JsonIgnore] + public bool LinkedChildrenLoaded { get; private set; } [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } @@ -270,6 +286,27 @@ namespace MediaBrowser.Controller.Entities return GetCachedChildren(); } + /// <summary> + /// Drops the children this folder has materialised, and the ones held by every folder below + /// it, without loading anything that is not already in memory. + /// </summary> + public void ReleaseCachedChildren() + { + // Cleared before descending, so a folder already on the way down is not walked twice. + var children = _children; + _children = null; + + if (children is null) + { + return; + } + + foreach (var child in children) + { + (child as Folder)?.ReleaseCachedChildren(); + } + } + public override double? GetRefreshProgress() { return ProviderManager.GetRefreshProgress(Id); @@ -300,7 +337,7 @@ namespace MediaBrowser.Controller.Entities var dictionary = new Dictionary<Guid, BaseItem>(); Children = null; // invalidate cached children. - var childrenList = Children.ToList(); + var childrenList = GetChildrenForValidation(); foreach (var child in childrenList) { @@ -354,6 +391,9 @@ namespace MediaBrowser.Controller.Entities { ProviderManager.OnRefreshComplete(this); } + + // The subtree is done with, so stop holding it. + ReleaseCachedChildren(); } } @@ -389,19 +429,23 @@ namespace MediaBrowser.Controller.Entities if (IsFileProtocol) { - IEnumerable<BaseItem> nonCachedChildren = []; + IEnumerable<BaseItem> nonCachedChildren; try { - nonCachedChildren = GetNonCachedChildren(directoryService); + // Finish enumeration before mutating the library. An I/O failure, including + // one partway through a lazy enumeration, must not look like removed files. + nonCachedChildren = GetNonCachedChildren(directoryService).ToArray(); } catch (IOException ex) { Logger.LogError(ex, "Error retrieving children from file system"); + return; } catch (SecurityException ex) { Logger.LogError(ex, "Error retrieving children from file system"); + return; } catch (Exception ex) { @@ -535,7 +579,7 @@ namespace MediaBrowser.Controller.Entities && primaryVideo.OwnerId.IsEmpty() && (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p))) { - var newPrimary = newItems + var newPrimary = validChildren .OfType<Video>() .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) .Any(p => (primaryVideo.LocalAlternateVersions ?? []) @@ -577,6 +621,8 @@ namespace MediaBrowser.Controller.Entities newPrimary.Name, newPrimary.Id); + await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false); + // Reroute collection/playlist references from old primary to new primary await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false); @@ -605,9 +651,12 @@ namespace MediaBrowser.Controller.Entities LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false); } - // Demote old primaries that are now alternate versions of newly created primaries. + // Demote old primaries that are now alternate versions of another primary. // This handles the case where a new file is added that becomes the new primary - // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion). + // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion), and the case + // where the file that takes over was already in the library and merely traded + // places with this one — so the new primary is looked up among all valid children + // rather than only the newly created ones. // Items in replacedPrimaries are excluded (already in actuallyRemoved). var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>(); foreach (var item in itemsRemoved.Except(actuallyRemoved)) @@ -617,7 +666,7 @@ namespace MediaBrowser.Controller.Entities && !string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path)) { - var newPrimary = newItems + var newPrimary = validChildren .OfType<Video>() .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) .Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase))); @@ -637,10 +686,13 @@ namespace MediaBrowser.Controller.Entities newPrimary.Name, newPrimary.Id); + await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false); + // First: update old primary's alternate items to point to new primary. // Order matters — update alternates FIRST so they don't get orphan-deleted // when old primary's arrays are cleared. - var oldAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary) + var oldLocalAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet(); + var oldAlternateIds = oldLocalAlternateIds .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id)) .Distinct() .ToList(); @@ -650,7 +702,10 @@ namespace MediaBrowser.Controller.Entities if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id)) { altVideo.SetPrimaryVersionId(newPrimary.Id); - altVideo.OwnerId = newPrimary.Id; + + // Only a version stored next to the new primary is owned by it; one that + // was merged in by hand keeps its own row and must stay unowned. + altVideo.OwnerId = oldLocalAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty; await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); } } @@ -756,6 +811,23 @@ namespace MediaBrowser.Controller.Entities } } + private async Task PromoteToPrimaryVersionAsync(Video newPrimary, CancellationToken cancellationToken) + { + if (!newPrimary.PrimaryVersionId.HasValue && newPrimary.OwnerId.IsEmpty()) + { + return; + } + + Logger.LogInformation( + "Promoting {Name} ({Id}) to the primary version of its group", + newPrimary.Name, + newPrimary.Id); + + newPrimary.SetPrimaryVersionId(null); + newPrimary.OwnerId = Guid.Empty; + await newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + private async Task RefreshMetadataRecursive(IList<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken) { await RunTasks( @@ -791,7 +863,14 @@ namespace MediaBrowser.Controller.Entities if (recursive && child is Folder folder) { folder.Children = null; // invalidate cached children. - await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); + try + { + await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + folder.ReleaseCachedChildren(); + } } } } @@ -860,6 +939,17 @@ namespace MediaBrowser.Controller.Entities }); } + private IReadOnlyList<BaseItem> GetChildrenForValidation() + { + return ItemRepository.GetItemList(new InternalItemsQuery + { + Parent = this, + GroupByPresentationUniqueKey = false, + IncludeAlternateVersions = true, + DtoOptions = new DtoOptions(true) + }); + } + public virtual int GetChildCount(User user) { if (LinkedChildren.Length > 0) @@ -1085,15 +1175,7 @@ namespace MediaBrowser.Controller.Entities items = ApplyNameFilter(items, query); } - 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; + return UserViewBuilder.SortAndPage(items, null, query, LibraryManager); } private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query) @@ -1605,7 +1687,17 @@ namespace MediaBrowser.Controller.Entities /// <returns>IEnumerable{BaseItem}.</returns> public List<BaseItem> GetLinkedChildren() { - var resolved = ResolveLinkedChildren(LinkedChildren); + return GetLinkedChildren(new DtoOptions()); + } + + /// <summary> + /// Gets the linked children, populating only what <paramref name="options"/> asks for. + /// </summary> + /// <param name="options">Fields to populate on the resolved children.</param> + /// <returns>The resolved children.</returns> + public List<BaseItem> GetLinkedChildren(DtoOptions options) + { + var resolved = ResolveLinkedChildren(LinkedChildren, options); var list = new List<BaseItem>(resolved.Count); foreach (var (_, item) in resolved) { @@ -1722,8 +1814,9 @@ namespace MediaBrowser.Controller.Entities /// path (legacy path-based resolution). /// </summary> /// <param name="linkedChildren">Linked children to resolve.</param> + /// <param name="options">Fields to populate on the resolved items; all fields when null.</param> /// <returns>Each input entry paired with its resolved item; entries that fail to resolve are dropped.</returns> - private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren) + private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren, DtoOptions options = null) { var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count); if (linkedChildren.Count == 0) @@ -1745,7 +1838,8 @@ namespace MediaBrowser.Controller.Entities { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToBatch] + ItemIds = [.. idsToBatch], + DtoOptions = options ?? new DtoOptions() }); byId = new Dictionary<Guid, BaseItem>(batched.Count); foreach (var item in batched) diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index 6ec78a270e..ef8acaef92 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -83,10 +83,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.GenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs index f47d2162f7..0cdc8bce03 100644 --- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs +++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs @@ -1,12 +1,13 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Entities { + /// <summary> + /// Interface for items that have special features. + /// </summary> public interface IHasSpecialFeatures { /// <summary> diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs index dab15eb018..47df09d1ce 100644 --- a/MediaBrowser.Controller/Entities/IHasStartDate.cs +++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs @@ -1,11 +1,15 @@ -#pragma warning disable CS1591 - using System; namespace MediaBrowser.Controller.Entities { + /// <summary> + /// Interface for items that have a start date. + /// </summary> public interface IHasStartDate { + /// <summary> + /// Gets or sets the start date. + /// </summary> DateTime StartDate { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs index 4928bda7a2..756dbecb98 100644 --- a/MediaBrowser.Controller/Entities/IItemByName.cs +++ b/MediaBrowser.Controller/Entities/IItemByName.cs @@ -1,19 +1,28 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; namespace MediaBrowser.Controller.Entities { /// <summary> - /// Marker interface. + /// Marker interface for items that represent a name, like a genre or a studio. /// </summary> public interface IItemByName { + /// <summary> + /// Gets the items tagged with this name. + /// </summary> + /// <param name="query">The query.</param> + /// <returns>The tagged items.</returns> IReadOnlyList<BaseItem> GetTaggedItems(InternalItemsQuery query); } + /// <summary> + /// Interface for by-name items that can also be accessed as a regular library item. + /// </summary> public interface IHasDualAccess : IItemByName { + /// <summary> + /// Gets a value indicating whether the item is accessed by name. + /// </summary> bool IsAccessedByName { get; } } } diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs index cdda8ea399..0f8904df5c 100644 --- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs +++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Entities { + /// <summary> + /// Interface for items that can be placeholders. + /// </summary> public interface ISupportsPlaceHolders { /// <summary> diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 3b1f6a961f..7c88d5dd05 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -103,6 +103,7 @@ namespace MediaBrowser.Controller.Entities || SubtitleLanguages.Count > 0 || LinkedChildAncestorIds.Length > 0 || AncestorIds.Length > 0 + || DescendantOfId.HasValue || IsFavorite.HasValue || IsFavoriteOrLiked.HasValue || IsLiked.HasValue @@ -368,6 +369,13 @@ namespace MediaBrowser.Controller.Entities /// </summary> public Guid[] LinkedChildAncestorIds { get; set; } + /// <summary> + /// Gets or sets the id of a folder whose descendants the items must be part of. + /// Unlike <see cref="AncestorIds"/> this also follows the linked children of BoxSets and + /// Playlists, so it reaches the items below a linked folder (a Series' episodes, for example). + /// </summary> + public Guid? DescendantOfId { get; set; } + public Guid[] TopParentIds { get; set; } public CollectionType?[] PresetViews { get; set; } @@ -424,12 +432,18 @@ namespace MediaBrowser.Controller.Entities public string? HasNoSubtitleTrackWithLanguage { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to return only items nothing names any more. + /// </summary> public bool? IsDeadArtist { get; set; } public bool? IsDeadStudio { get; set; } public bool? IsDeadGenre { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to return only items nothing names any more. + /// </summary> public bool? IsDeadPerson { get; set; } /// <summary> @@ -474,6 +488,14 @@ namespace MediaBrowser.Controller.Entities /// </summary> public bool IncludeOwnedItems { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to include alternate versions, which carry a + /// <see cref="Video.PrimaryVersionId"/> and are normally hidden behind the version they + /// belong to. Unlike <see cref="IncludeOwnedItems"/> this keeps the versions a user merged + /// by hand without also returning the parts and extras owned by another item. + /// </summary> + public bool IncludeAlternateVersions { get; set; } + public bool? Is4K { get; set; } public int? MaxHeight { get; set; } @@ -496,6 +518,12 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList<string> SubtitleLanguages { get; set; } + /// <summary> + /// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>. + /// Filters that only exist to hide content can be skipped entirely when this is false. + /// </summary> + public bool UserHasContentRestrictions { get; private set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; @@ -519,6 +547,7 @@ namespace MediaBrowser.Controller.Entities .Select(tag => tag.RemoveDiacritics().ToLowerInvariant()) .ToArray(); + UserHasContentRestrictions = user.HasContentRestrictions(); User = user; } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index e12ba22343..8d2a959f4d 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities { PersonTypes = personTypes; ExcludePersonTypes = excludePersonTypes; + EnableTotalRecordCount = true; } + /// <summary> + /// Gets or sets a value indicating whether to count the matching people. Under an + /// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops + /// at the limit, the count has to check every person. + /// </summary> + public bool EnableTotalRecordCount { get; set; } + public int? StartIndex { get; set; } /// <summary> @@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities public User User { get; set; } public bool? IsFavorite { get; set; } + + /// <summary> + /// Gets or sets the item query whose access settings (library access, parental rating, tags) + /// people must satisfy through at least one of the items they are credited on. + /// </summary> + public InternalItemsQuery AccessFilter { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 8216937cad..16d8bfe391 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -11,6 +11,7 @@ using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Querying; @@ -89,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies return base.GetNonCachedChildren(directoryService); } - return Enumerable.Empty<BaseItem>(); + return []; } protected override IReadOnlyList<BaseItem> LoadChildren() @@ -168,14 +169,21 @@ namespace MediaBrowser.Controller.Entities.Movies return true; } - var userLibraryFolderIds = GetLibraryFolderIds(user); - var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds(); + List<BaseItem> linkedItems = null; + var libraryFolderIds = LibraryFolderIds; + if (libraryFolderIds is null) + { + linkedItems = GetLinkedChildren(DtoOptions.StoredColumnsOnly); + libraryFolderIds = GetLibraryFolderIds(linkedItems); + } if (libraryFolderIds.Length == 0) { return true; } + var userLibraryFolderIds = GetLibraryFolderIds(user); + if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i))) { return false; @@ -184,7 +192,7 @@ namespace MediaBrowser.Controller.Entities.Movies // If user has parental controls, hide the BoxSet when all children are restricted if (user.MaxParentalRatingScore.HasValue) { - var linkedItems = GetLinkedChildren(); + linkedItems ??= GetLinkedChildren(DtoOptions.StoredColumnsOnly); if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true))) { return false; @@ -241,10 +249,19 @@ namespace MediaBrowser.Controller.Entities.Movies public Guid[] GetLibraryFolderIds() { - var expandedFolders = new List<Guid>(); + return GetLibraryFolderIds(GetLinkedChildren(DtoOptions.StoredColumnsOnly)); + } + + private Guid[] GetLibraryFolderIds(IEnumerable<BaseItem> linkedChildren) + { + // Seeded with this box set so a cycle through a nested collection terminates. + var expandedFolders = new List<Guid> { Id }; + + // The user root children are the same for every item. + var rootChildren = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); - return FlattenItems(this, expandedFolders) - .SelectMany(LibraryManager.GetCollectionFolders) + return FlattenItems(linkedChildren, expandedFolders) + .SelectMany(i => LibraryManager.GetCollectionFolders(i, rootChildren)) .Select(i => i.Id) .Distinct() .ToArray(); @@ -264,13 +281,13 @@ namespace MediaBrowser.Controller.Entities.Movies { expandedFolders.Add(item.Id); - return FlattenItems(boxset.GetLinkedChildren(), expandedFolders); + return FlattenItems(boxset.GetLinkedChildren(DtoOptions.StoredColumnsOnly), expandedFolders); } - return Array.Empty<BaseItem>(); + return []; } - return new[] { item }; + return [item]; } } } diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index e8817a29cf..8c3ce2ff58 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -90,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/MusicVideo.cs b/MediaBrowser.Controller/Entities/MusicVideo.cs index 237ad5198c..effbf98820 100644 --- a/MediaBrowser.Controller/Entities/MusicVideo.cs +++ b/MediaBrowser.Controller/Entities/MusicVideo.cs @@ -40,7 +40,7 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/PeopleHelper.cs b/MediaBrowser.Controller/Entities/PeopleHelper.cs index 24b1843ce6..29f238d8ea 100644 --- a/MediaBrowser.Controller/Entities/PeopleHelper.cs +++ b/MediaBrowser.Controller/Entities/PeopleHelper.cs @@ -35,57 +35,61 @@ namespace MediaBrowser.Controller.Entities person.Type = PersonKind.Writer; } - // If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes - if (person.Type == PersonKind.GuestStar) - { - var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor); + // Check for dupes based on the combination of Name, Type and Role. + var existing = people.FirstOrDefault(p => IsSameCredit(p, person) + && string.Equals(p.Role ?? string.Empty, person.Role ?? string.Empty, StringComparison.OrdinalIgnoreCase)); - if (existing is not null) - { - existing.Type = PersonKind.GuestStar; - MergeExisting(existing, person); - return; - } - } - - if (person.Type == PersonKind.Actor) + if (existing is null) { - // If the actor already exists without a role and we have one, fill it in - var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar)); - if (existing is null) + if (string.IsNullOrEmpty(person.Role)) { - // Wasn't there - add it - people.Add(person); + existing = people.FirstOrDefault(p => IsSameCredit(p, person)); } else { - // Was there, if no role and we have one - fill it in - if (string.IsNullOrEmpty(existing.Role) && !string.IsNullOrEmpty(person.Role)) + // If the person already exists without a role and we have one, fill it in + existing = people.FirstOrDefault(p => IsSameCredit(p, person) && string.IsNullOrEmpty(p.Role)); + if (existing is not null) { existing.Role = person.Role; } - - MergeExisting(existing, person); } } - else + + if (existing is null) { - var existing = people.FirstOrDefault(p => - string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase) - && p.Type == person.Type); + people.Add(person); + return; + } - // Check for dupes based on the combination of Name and Type - if (existing is null) - { - people.Add(person); - } - else - { - MergeExisting(existing, person); - } + // If the type is GuestStar and there's already an Actor entry, then promote it to avoid dupes + if (person.Type == PersonKind.GuestStar) + { + existing.Type = PersonKind.GuestStar; } + + MergeExisting(existing, person); } + private static bool IsSameCredit(PersonInfo existing, PersonInfo person) + { + if (!string.Equals(existing.Name, person.Name, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Actor and GuestStar describe the same credit, a guest star is just a promoted actor. + if (IsCastKind(existing.Type) && IsCastKind(person.Type)) + { + return true; + } + + return existing.Type == person.Type; + } + + private static bool IsCastKind(PersonKind kind) + => kind is PersonKind.Actor or PersonKind.GuestStar; + private static void MergeExisting(PersonInfo existing, PersonInfo person) { existing.SortOrder = person.SortOrder ?? existing.SortOrder; diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index 5cc4d322f7..bba5005eed 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -75,6 +76,16 @@ namespace MediaBrowser.Controller.Entities return false; } + /// <inheritdoc /> + /// <remarks> + /// People don't carry the tags of the media they appear in, so the allowed tags check + /// is skipped for them; otherwise no person would be visible to users with allowed tags configured. + /// </remarks> + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) + { + return base.IsVisible(user, true); + } + public override bool IsSaveLocalMetadataEnabled() { return true; @@ -87,10 +98,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validFilename = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validFilename = normalizeName ? GetItemByNameFolderName(name) : name; string subFolderPrefix = null; diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs index be19e1bdae..97aa22dc04 100644 --- a/MediaBrowser.Controller/Entities/SourceType.cs +++ b/MediaBrowser.Controller/Entities/SourceType.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Entities { + /// <summary> + /// The source of an item. + /// </summary> public enum SourceType { + /// <summary> + /// The item comes from a library. + /// </summary> Library = 0, + + /// <summary> + /// The item comes from a channel. + /// </summary> Channel = 1, + + /// <summary> + /// The item comes from live TV. + /// </summary> LiveTV = 2 } } diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs index 9103b09a95..a944b356c8 100644 --- a/MediaBrowser.Controller/Entities/Studio.cs +++ b/MediaBrowser.Controller/Entities/Studio.cs @@ -78,10 +78,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.StudioPath, validName); } diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 42e4f79942..40f917d50c 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Controller.Entities.TV public int? IndexNumberEnd { get; set; } [JsonIgnore] - protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1; + protected override bool SupportsOwnedItems => IsStacked || LocalAlternateVersions.Length > 0 || MediaSourceCount > 1; [JsonIgnore] public override bool SupportsInheritedParentImages => true; diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 952187c6e1..126f4361ba 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -82,9 +82,15 @@ namespace MediaBrowser.Controller.Entities.TV { var userdatakeys = GetUserDataKeys(); - if (userdatakeys.Count > 1) + // The first user data key is a stable cross-folder identity. + // When none exists, fall back to the (normalized) series name. + var groupingKey = userdatakeys.Count > 1 + ? userdatakeys[0] + : GetNameBasedGroupingKey(); + + if (!string.IsNullOrEmpty(groupingKey)) { - return AddLibrariesToPresentationUniqueKey(userdatakeys[0]); + return AddLibrariesToPresentationUniqueKey(groupingKey); } } @@ -101,6 +107,7 @@ namespace MediaBrowser.Controller.Entities.TV var folders = LibraryManager.GetCollectionFolders(this) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) + .Order(StringComparer.Ordinal) .ToArray(); if (folders.Length == 0) @@ -111,6 +118,14 @@ namespace MediaBrowser.Controller.Entities.TV return key + "-" + string.Join('-', folders); } + private string GetNameBasedGroupingKey() + { + // Prefix with the type so a series can never collide with a same-named item of another kind. + return string.IsNullOrEmpty(Name) + ? null + : "series-" + Name.ToLowerInvariant(); + } + private static string GetUniqueSeriesKey(BaseItem series) { return series.GetPresentationUniqueKey(); @@ -120,20 +135,19 @@ namespace MediaBrowser.Controller.Entities.TV { var seriesKey = GetUniqueSeriesKey(this); - var result = LibraryManager.GetCount(new InternalItemsQuery(user) + var result = LibraryManager.GetItemIds(new InternalItemsQuery(user) { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, IncludeItemTypes = new[] { BaseItemKind.Season }, IsVirtualItem = false, - Limit = 0, DtoOptions = new DtoOptions(false) { EnableImages = false } }); - return result; + return result.Count; } public override int GetRecursiveChildCount(User user) @@ -188,6 +202,25 @@ namespace MediaBrowser.Controller.Entities.TV return list; } + /// <inheritdoc /> + protected override Guid[] GetExtraOwnerIds() + { + if (!LibraryManager.GetLibraryOptions(this).EnableAutomaticSeriesGrouping) + { + return base.GetExtraOwnerIds(); + } + + // Setting PresentationUniqueKey on the query disables presentation-key grouping, so this + // returns every folder-item of the merged series rather than the collapsed survivor. + var ids = LibraryManager.GetItemIds(new InternalItemsQuery + { + PresentationUniqueKey = GetPresentationUniqueKey(), + IncludeItemTypes = [BaseItemKind.Series] + }); + + return ids.Count == 0 ? base.GetExtraOwnerIds() : ids.ToArray(); + } + public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { return GetSeasons(user, new DtoOptions(true)); @@ -300,6 +333,19 @@ namespace MediaBrowser.Controller.Entities.TV public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { Children = null; // invalidate cached children. + + try + { + await RefreshAllMetadataInternal(refreshOptions, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + ReleaseCachedChildren(); + } + } + + private async Task RefreshAllMetadataInternal(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) + { // Refresh bottom up, seasons and episodes first, then the series var items = GetRecursiveChildren(); @@ -507,7 +553,7 @@ namespace MediaBrowser.Controller.Entities.TV { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs index 939709215c..a2465eedf0 100644 --- a/MediaBrowser.Controller/Entities/Trailer.cs +++ b/MediaBrowser.Controller/Entities/Trailer.cs @@ -49,7 +49,7 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index c57ed2faf8..82256cd964 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -455,24 +455,34 @@ namespace MediaBrowser.Controller.Entities { var itemList = filtered.ToList(); var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList(); + var leaves = itemList.Where(i => i is not Folder).ToList(); + var isPlayedValue = query.IsPlayed.Value; - if (folderIds.Count > 0) - { - var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); - var isPlayedValue = query.IsPlayed.Value; + var counts = folderIds.Count > 0 + ? libraryManager.GetPlayedAndTotalCountBatch(folderIds, user) + : null; + + // A movie held as several files is watched once any of its versions is watched. + var resumeData = leaves.Count > 0 + ? userDataManager.GetResumeUserDataBatch(leaves, user) + : null; - return itemList.Where(i => + return itemList.Where(item => + { + if (item is Folder) { - if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) - { - return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; - } + var itemCount = counts?.GetValueOrDefault(item.Id) ?? default; + return (itemCount.Played >= itemCount.Total) == isPlayedValue; + } - return true; - }); - } + var played = userDataManager.GetUserData(user, item)?.Played ?? false; + if (!played && resumeData is not null && resumeData.TryGetValue(item.Id, out var versionData)) + { + played = versionData.UserData.Played; + } - return itemList; + return played == isPlayedValue; + }); } return filtered; @@ -490,6 +500,13 @@ namespace MediaBrowser.Controller.Entities } var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray(); + + // Adjacency is defined by the order the query asked for, so it has to run after sorting but before paging. + if (!query.AdjacentTo.IsNullOrEmpty()) + { + itemsArray = FilterForAdjacency(itemsArray, query.AdjacentTo.Value).ToArray(); + } + var totalCount = itemsArray.Length; if (query.Limit.HasValue && query.Limit.Value > 0) @@ -598,19 +615,7 @@ namespace MediaBrowser.Controller.Entities } } - if (query.IsPlayed.HasValue) - { - // Folder.IsPlayed() hits the DB per-item (N+1 queries). - // Folders are batch-filtered by the collection Filter() overload. - if (!item.IsFolder) - { - userData ??= userDataManager.GetUserData(user, item); - if (item.IsPlayed(user, userData) != query.IsPlayed.Value) - { - return false; - } - } - } + // IsPlayed is answered by the collection Filter() overload for folders and leaves alike. if (query.IsLocked.HasValue) { @@ -730,7 +735,7 @@ namespace MediaBrowser.Controller.Entities // Apply year filter if (query.Years.Length > 0) { - if (!(item.ProductionYear.HasValue && query.Years.Contains(item.ProductionYear.Value))) + if (item.ProductionYear is null || !query.Years.Contains(item.ProductionYear.Value)) { return false; } @@ -886,26 +891,32 @@ namespace MediaBrowser.Controller.Entities return _userViewManager.GetUserSubView(parent.Id, type, localizationKey, sortName); } - public static IEnumerable<BaseItem> FilterForAdjacency(List<BaseItem> list, Guid adjacentTo) + /// <summary> + /// Trims an ordered list down to the requested item and its immediate neighbours. + /// </summary> + /// <param name="list">The items in the order the query returned them.</param> + /// <param name="adjacentTo">The id of the item to return the neighbours of.</param> + /// <returns>The previous item, the requested item and the next item, in order.</returns> + public static IEnumerable<BaseItem> FilterForAdjacency(IReadOnlyList<BaseItem> list, Guid adjacentTo) { - var adjacentToItem = list.FirstOrDefault(i => i.Id.Equals(adjacentTo)); - - var index = list.IndexOf(adjacentToItem); - - var previousId = Guid.Empty; - var nextId = Guid.Empty; - - if (index > 0) + var index = -1; + for (var i = 0; i < list.Count; i++) { - previousId = list[index - 1].Id; + if (list[i].Id.Equals(adjacentTo)) + { + index = i; + break; + } } - if (index < list.Count - 1) + // The item isn't part of this result set, so it has no neighbours in it either. + if (index < 0) { - nextId = list[index + 1].Id; + return []; } - return list.Where(i => i.Id.Equals(previousId) || i.Id.Equals(nextId) || i.Id.Equals(adjacentTo)); + var start = Math.Max(index - 1, 0); + return list.Skip(start).Take(Math.Min(index + 2, list.Count) - start); } } } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 0606fe1870..e2f91aa04a 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -527,7 +527,13 @@ namespace MediaBrowser.Controller.Entities protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + var hasChanges = false; + + // The extras of a version group are maintained by its primary. + if (!PrimaryVersionId.HasValue) + { + hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + } // Clean up LocalAlternateVersions - remove paths that no longer exist if (LocalAlternateVersions.Length > 0) @@ -588,10 +594,20 @@ namespace MediaBrowser.Controller.Entities { altVideo.OwnerId = Id; altVideo.SetPrimaryVersionId(Id); + altVideo.IsInMixedFolder = IsInMixedFolder; LibraryManager.CreateItem(altVideo, GetParent()); } } + // A version is resolved on its own, so it does not learn whether the folder it sits in + // holds other items. It has to share that with the version it belongs to, before the + // refresh below acts on it. + if (LibraryManager.GetItemById(id) is Video resolvedVersion && resolvedVersion.IsInMixedFolder != IsInMixedFolder) + { + resolvedVersion.IsInMixedFolder = IsInMixedFolder; + await resolvedVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false); // Create LinkedChild entry for this local alternate version @@ -671,6 +687,7 @@ namespace MediaBrowser.Controller.Entities video.Id = id; video.OwnerId = Id; + video.IsInMixedFolder = IsInMixedFolder; LibraryManager.CreateItem(video, parentFolder); newOptions.ForceSave = true; } @@ -751,6 +768,80 @@ namespace MediaBrowser.Controller.Entities .ToArray(); } + /// <inheritdoc /> + protected override Guid[] GetOwnedVersionIds() + { + // Only the versions that live beside this one in the folder this scan covers. Linked + // versions are items of their own and maintain their extras themselves. + return [Id, .. LibraryManager.GetLocalAlternateVersionIds(this)]; + } + + /// <inheritdoc /> + protected override Guid GetOwnerIdForExtra(BaseItem extra) + { + if (string.IsNullOrEmpty(extra.Path)) + { + return Id; + } + + var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan()); + var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan()); + + var ownerId = Id; + var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName); + + foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this)) + { + var version = LibraryManager.GetItemById(versionId); + if (version is null) + { + continue; + } + + // "Movie - [2160p]-trailer.mkv" belongs to "Movie - [2160p].mkv" rather than to the + // primary version, whose name it also starts with when the primary is plain "Movie.mkv" + var length = MatchedVersionNameLength(version.Path, extraDirectory, extraFileName); + if (length > matchedLength) + { + matchedLength = length; + ownerId = versionId; + } + } + + return ownerId; + } + + /// <summary> + /// Gets how much of an extra's file name is the name of the given version file, or 0 when the + /// extra is not named after it. + /// </summary> + /// <param name="versionPath">The path of the version.</param> + /// <param name="extraDirectory">The directory the extra lives in.</param> + /// <param name="extraFileName">The file name of the extra, without extension.</param> + /// <returns>The length of the match.</returns> + private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan<char> extraDirectory, ReadOnlySpan<char> extraFileName) + { + if (string.IsNullOrEmpty(versionPath) + || !System.IO.Path.GetDirectoryName(versionPath.AsSpan()).Equals(extraDirectory, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + var versionFileName = System.IO.Path.GetFileNameWithoutExtension(versionPath.AsSpan()); + if (versionFileName.IsEmpty || !extraFileName.StartsWith(versionFileName, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + // The version name has to end where the extra's own name begins, so that a version + // named "Movie - 4K" does not claim the extras of "Movie - 4Kish" + var remainder = extraFileName[versionFileName.Length..]; + + return !remainder.IsEmpty && (remainder[0] == ' ' || Array.IndexOf(VersionDelimiters, remainder[0]) >= 0) + ? versionFileName.Length + : 0; + } + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() { var primary = PrimaryVersionId.HasValue diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index 37820296cc..03fb2156d3 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -85,10 +85,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.YearPath, validName); } diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs index 44b7fadf5e..b2d2273cbe 100644 --- a/MediaBrowser.Controller/IO/FileSystemHelper.cs +++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs @@ -166,4 +166,40 @@ public static class FileSystemHelper return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget); } + + /// <summary> + /// Combines a caller supplied name with a parent directory, making sure the name cannot escape that directory. + /// </summary> + /// <param name="parentPath">The directory the name has to resolve inside of.</param> + /// <param name="name">The name of the child.</param> + /// <returns> + /// The full path of the child, or <c>null</c> if <paramref name="name"/> is not the name of a direct child + /// of <paramref name="parentPath"/>. + /// </returns> + public static string? GetChildPath(string parentPath, string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Contains('\0', StringComparison.Ordinal)) + { + return null; + } + + // Rejects directory separators, and on Windows also volume separators, as those make the name more than a single segment. + if (!string.Equals(Path.GetFileName(name), name, StringComparison.Ordinal)) + { + return null; + } + + var fullPath = Path.GetFullPath(Path.Combine(parentPath, name)); + var fullParentPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parentPath)); + + // Catches the remaining relative names, "." and "..", which are valid single segments. + if (!string.Equals(Path.GetDirectoryName(fullPath), fullParentPath, StringComparison.Ordinal)) + { + return null; + } + + // Windows strips trailing dots and spaces, so a name like "..." resolves to the parent directory itself + // and a name like "Movies." to a different child. Reject anything normalization did not leave intact. + return string.Equals(Path.GetFileName(fullPath), name, StringComparison.Ordinal) ? fullPath : null; + } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 0b64da291c..82de3546f0 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -107,6 +107,13 @@ namespace MediaBrowser.Controller.Library Person? GetPerson(string name); /// <summary> + /// Gets a Person, creating and persisting it if no item exists for the name yet. + /// </summary> + /// <param name="name">The name of the person.</param> + /// <returns>The person.</returns> + Person GetOrCreatePerson(string name); + + /// <summary> /// Finds the by path. /// </summary> /// <param name="path">The path.</param> @@ -153,15 +160,6 @@ namespace MediaBrowser.Controller.Library Year GetYear(int value); /// <summary> - /// Validate and refresh the People sub-set of the IBN. - /// The items are stored in the db but not loaded into memory until actually requested by an operation. - /// </summary> - /// <param name="progress">The progress.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken); - - /// <summary> /// Reloads the root media folder. /// </summary> /// <param name="progress">The progress.</param> @@ -256,6 +254,14 @@ namespace MediaBrowser.Controller.Library IEnumerable<Video> GetLinkedAlternateVersions(Video video); /// <summary> + /// Gets, in a single query, the subset of the supplied items that own at least one alternate + /// version (local or linked). Items absent from the result have no alternate versions. + /// </summary> + /// <param name="itemIds">The item IDs to check.</param> + /// <returns>The set of item IDs that have alternate versions.</returns> + IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds); + + /// <summary> /// Creates or updates a LinkedChild entry linking a parent to a child item. /// </summary> /// <param name="parentId">The parent item ID.</param> @@ -598,6 +604,12 @@ namespace MediaBrowser.Controller.Library IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query); /// <summary> + /// Deletes every credit that no item maps to any more. + /// </summary> + /// <returns>The number of credits that were deleted.</returns> + int DeleteOrphanedCredits(); + + /// <summary> /// Gets the distinct people names per item for multiple items. /// </summary> /// <param name="itemIds">The item IDs.</param> @@ -606,6 +618,13 @@ namespace MediaBrowser.Controller.Library IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes); /// <summary> + /// Gets the people for multiple items in a single query, keyed by item id. + /// </summary> + /// <param name="itemIds">The item IDs.</param> + /// <returns>A dictionary mapping each item ID to its people. Items with no people are omitted.</returns> + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds); + + /// <summary> /// Queries the items. /// </summary> /// <param name="query">The query.</param> @@ -688,6 +707,14 @@ namespace MediaBrowser.Controller.Library /// <returns><c>true</c> if ignored, <c>false</c> otherwise.</returns> bool IgnoreFile(FileSystemMetadata file, BaseItem parent); + /// <summary> + /// Gets the id a <see cref="Person"/> item for the name would have, without looking it up + /// or creating it. + /// </summary> + /// <param name="name">The name of the person.</param> + /// <returns>The item id for the name.</returns> + Guid GetPersonId(string name); + Guid GetStudioId(string name); Guid GetGenreId(string name); @@ -733,13 +760,25 @@ namespace MediaBrowser.Controller.Library ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user); /// <summary> + /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned + /// item value - artists, genres and studios - are answered in one set of queries for the + /// whole batch; the rest fall back to one query per item. + /// </summary> + /// <param name="kind">The kind of the name items.</param> + /// <param name="ids">The IDs of the name items.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="user">The user for access filtering.</param> + /// <returns>The item counts of each requested id.</returns> + Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user); + + /// <summary> /// Batch-fetches child counts for multiple parent folders. /// Returns the count of immediate children (non-recursive) for each parent. /// </summary> /// <param name="parentIds">The list of parent folder IDs.</param> - /// <param name="userId">The user ID for access filtering.</param> + /// <param name="user">The user the counts are for, or null to count without a user's preferences.</param> /// <returns>Dictionary mapping parent ID to child count.</returns> - Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user); /// <summary> /// Batch-fetches played and total counts for multiple folder items. @@ -794,10 +833,25 @@ namespace MediaBrowser.Controller.Library QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); /// <summary> + /// Gets a list of all distinct tags of the matching items. + /// </summary> + /// <param name="query">The query filter.</param> + /// <returns>List of tags.</returns> + IReadOnlyList<string> GetTagNames(InternalItemsQuery query); + + /// <summary> /// Gets a list of all language codes of the provided stream type. /// </summary> /// <param name="mediaStreamType">The stream type.</param> /// <returns>List of language codes.</returns> IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); + + /// <summary> + /// Gets a list of all language codes for the matching items and the the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The stream type.</param> + /// <param name="query">The query filter.</param> + /// <returns>List of language codes.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query); } } diff --git a/MediaBrowser.Controller/Library/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs index 845588c872..b1ff800fa0 100644 --- a/MediaBrowser.Controller/Library/SearchProviderQuery.cs +++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs @@ -19,7 +19,9 @@ public class SearchProviderQuery public Guid? UserId { get; init; } /// <summary> - /// Gets the item types to include in the search. + /// Gets the item types to include in the search. An empty array means every type is eligible. + /// When this is non-empty it is the authoritative type filter and <see cref="ExcludeItemTypes"/> + /// does not apply; excludes only take effect when no include types were requested. /// </summary> public BaseItemKind[] IncludeItemTypes { get; init; } = []; @@ -29,7 +31,9 @@ public class SearchProviderQuery public BaseItemKind[] ExcludeItemTypes { get; init; } = []; /// <summary> - /// Gets the media types to include in the search. + /// Gets the media types to include in the search. This is an additional constraint rather than + /// an alternative one: a provider must return only items that match both the requested media + /// types and the requested item types, not the union of the two. /// </summary> public MediaType[] MediaTypes { get; init; } = []; @@ -39,7 +43,9 @@ public class SearchProviderQuery public int? Limit { get; init; } /// <summary> - /// Gets the parent ID to scope the search. + /// Gets the parent ID to scope the search. This scopes to the whole subtree, not just direct + /// children - callers routinely pass a library folder id and expect items nested arbitrarily + /// deep beneath it (an episode under a season under a series) to match. /// </summary> public Guid? ParentId { get; init; } } diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs index 6da398129a..be75117b6f 100644 --- a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs +++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs @@ -17,7 +17,8 @@ namespace MediaBrowser.Controller.LibraryTaskScheduler; /// </summary> public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable { - private const int CleanupGracePeriod = 60; + private static readonly TimeSpan _cleanupGracePeriod = TimeSpan.FromSeconds(60); + private readonly IHostApplicationLifetime _hostApplicationLifetime; private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger; private readonly IServerConfigurationManager _serverConfigurationManager; @@ -31,6 +32,8 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr private readonly Lock _taskLock = new(); private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>(); + private readonly CancellationTokenSource _disposeTokenSource = new(); + private readonly TimeSpan _gracePeriod; private volatile int _workCounter; private Task? _cleanupTask; @@ -46,10 +49,34 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr IHostApplicationLifetime hostApplicationLifetime, ILogger<LimitedConcurrencyLibraryScheduler> logger, IServerConfigurationManager serverConfigurationManager) + : this(hostApplicationLifetime, logger, serverConfigurationManager, _cleanupGracePeriod) + { + } + + internal LimitedConcurrencyLibraryScheduler( + IHostApplicationLifetime hostApplicationLifetime, + ILogger<LimitedConcurrencyLibraryScheduler> logger, + IServerConfigurationManager serverConfigurationManager, + TimeSpan gracePeriod) { _hostApplicationLifetime = hostApplicationLifetime; _logger = logger; _serverConfigurationManager = serverConfigurationManager; + _gracePeriod = gracePeriod; + } + + /// <summary> + /// Gets the number of runners the scheduler currently keeps alive. + /// </summary> + internal int ActiveRunnerCount + { + get + { + lock (_taskLock) + { + return _taskRunners.Count; + } + } } private void ScheduleTaskCleanup() @@ -68,31 +95,65 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr async Task RunCleanupTask() { - _logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod); - await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false); - if (_disposed) + while (true) { - _logger.LogDebug("Abort cleaning up, already disposed."); - return; - } + _logger.LogDebug("Schedule cleanup task in {CleanupGracePeriod}.", _gracePeriod); + try + { + await Task.Delay(_gracePeriod, _disposeTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Abort cleaning up, already disposed."); + return; + } - lock (_taskLock) - { - if (_tasks.Reader.Count > 0 || _workCounter > 0) + if (_disposed) { - _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(); + _logger.LogDebug("Abort cleaning up, already disposed."); return; } + + CancellationTokenSource[] runners; + lock (_taskLock) + { + if (_tasks.Reader.Count > 0 || _workCounter > 0) + { + _logger.LogDebug("Delay cleanup task, operations still running."); + // tasks are still there so its still in use. Wait another grace period. + // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended. + continue; + } + + runners = [.. _taskRunners.Keys]; + + // Retire the runners before they are told to stop: an operation starting while + // they wind down must spawn its own instead of counting these towards the fanout. + _taskRunners.Clear(); + + // Hand the next operation the ability to schedule a cleanup again. Without this + // the very first cleanup would be the only one that ever runs. + _cleanupTask = null; + } + + _logger.LogDebug("Cleanup runners."); + await StopRunners(runners).ConfigureAwait(false); + return; } + } + } - _logger.LogDebug("Cleanup runners."); - foreach (var item in _taskRunners.ToArray()) + private static async Task StopRunners(CancellationTokenSource[] runners) + { + foreach (var runner in runners) + { + try { - await item.Key.CancelAsync().ConfigureAwait(false); - _taskRunners.Remove(item.Key); + await runner.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // The runner already stopped on its own and disposed its stop source. } } } @@ -127,12 +188,17 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr { var stopToken = new CancellationTokenSource(); var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping); + + // Keyed on its own stop source, because cancelling that is what reaches the linked + // source the runner waits on. Cancellation does not travel the other way. + // Started without the runner's own token: a task cancelled before it is scheduled + // never runs its body, so it would never take itself out of _taskRunners again. _taskRunners.Add( - combinedSource, + stopToken, Task.Factory.StartNew( ItemWorker, - (combinedSource, stopToken), - combinedSource.Token, + (stopToken, combinedSource), + CancellationToken.None, TaskCreationOptions.PreferFairness, TaskScheduler.Default)); } @@ -145,7 +211,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr _deadlockDetector.Value = stopToken.TaskStop; try { - while (!stopToken.GlobalStop.Token.IsCancellationRequested) + while (!stopToken.GlobalStop.IsCancellationRequested) { var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false); try @@ -162,15 +228,24 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr } } } - catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested) + catch (OperationCanceledException) when (stopToken.GlobalStop.IsCancellationRequested) { // thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose. } + catch (ChannelClosedException) + { + // the scheduler was disposed and will not hand out any more work. + } finally { _logger.LogDebug("Cleanup Runner'."); _deadlockDetector.Value = default!; - _taskRunners.Remove(stopToken.TaskStop); + + lock (_taskLock) + { + _taskRunners.Remove(stopToken.TaskStop); + } + stopToken.GlobalStop.Dispose(); stopToken.TaskStop.Dispose(); } @@ -195,7 +270,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr finally { item.Progress.Report(100); - item.Done.SetResult(); + item.Done.TrySetResult(); } } @@ -285,16 +360,33 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr _disposed = true; _tasks.Writer.Complete(); - foreach (var item in _taskRunners) + + // Nobody is left to run these, so release whoever is waiting on them. + while (_tasks.Reader.TryRead(out var item)) { - await item.Key.CancelAsync().ConfigureAwait(false); + item.Done.TrySetResult(); } - if (_cleanupTask is not null) + CancellationTokenSource[] runners; + Task? cleanupTask; + lock (_taskLock) { - await _cleanupTask.ConfigureAwait(false); - _cleanupTask?.Dispose(); + runners = [.. _taskRunners.Keys]; + _taskRunners.Clear(); + cleanupTask = _cleanupTask; } + + await StopRunners(runners).ConfigureAwait(false); + + // Cuts the grace period short instead of holding up shutdown for the rest of it. + await _disposeTokenSource.CancelAsync().ConfigureAwait(false); + + if (cleanupTask is not null) + { + await cleanupTask.ConfigureAwait(false); + } + + _disposeTokenSource.Dispose(); } private class TaskQueueItem diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 8d59eef9f1..77e0087048 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -201,6 +201,13 @@ namespace MediaBrowser.Controller.LiveTv IEnumerable<User> GetEnabledUsers(); /// <summary> + /// Gets whether Live TV is enabled for a single user. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>Whether Live TV is enabled for the user.</returns> + bool IsEnabledForUser(User user); + + /// <summary> /// Gets the internal channels. /// </summary> /// <param name="query">The query.</param> diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 06188ad511..da2ffdeb79 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>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> @@ -18,7 +18,6 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="BitFaster.Caching" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> </ItemGroup> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 1b0bbe9ea0..6f010c0242 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -442,7 +442,8 @@ namespace MediaBrowser.Controller.MediaEncoding && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 || IsHdr10Plus(state.VideoStream) || IsDoviWithHdr10Bl(state.VideoStream) - || state.VideoStream.VideoRangeType == VideoRangeType.HLG); + || state.VideoStream.VideoRangeType == VideoRangeType.HLG + || state.VideoStream.VideoRangeType == VideoRangeType.DOVIInvalid); } private static bool IsDeinterlaceAvailable(EncodingJobInfo state) @@ -695,7 +696,11 @@ namespace MediaBrowser.Controller.MediaEncoding "ogg" or "oga" or "ogv" or "webm" or "webma" => "opus", "m4a" or "m4b" or "mp4" or "mov" or "mkv" or "mka" => "aac", "ts" or "avi" or "flv" or "f4v" or "swf" => "mp3", - _ => inferredCodec + // Containers that share their name with the codec they carry. + "aac" or "ac3" or "alac" or "dts" or "eac3" or "flac" or "mp2" or "mp3" or "opus" or "truehd" or "vorbis" => inferredCodec, + // Anything else - manifests such as m3u8/mpd in particular - names a container that + // is not an audio codec. Never hand that name to ffmpeg as an encoder. + _ => "aac" }; } @@ -1318,7 +1323,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(canvasArgs); } - arg.Append(" -i file:\"").Append(subtitlePath).Append('\"'); + arg.Append(" -i file:\"").Append(subtitlePath.EscapeProcessArgument()).Append('\"'); } if (state.AudioStream is not null && state.AudioStream.IsExternal) @@ -1330,7 +1335,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekAudioParam); } - arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"'); + arg.Append(" -i \"").Append(state.AudioStream.Path.EscapeProcessArgument()).Append('"'); } // Disable auto inserted SW scaler for HW decoders in case of changed resolution. @@ -1386,7 +1391,8 @@ namespace MediaBrowser.Controller.MediaEncoding or VideoRangeType.DOVIWithEL or VideoRangeType.DOVIWithHDR10Plus or VideoRangeType.DOVIWithELHDR10Plus - or VideoRangeType.DOVIInvalid; + || (rangeType == VideoRangeType.DOVIInvalid + && string.Equals(stream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)); // invalid may be hlg now } public static bool IsDovi(MediaStream stream) @@ -1396,7 +1402,8 @@ namespace MediaBrowser.Controller.MediaEncoding return IsDoviWithHdr10Bl(stream) || (rangeType is VideoRangeType.DOVI or VideoRangeType.DOVIWithHLG - or VideoRangeType.DOVIWithSDR); + or VideoRangeType.DOVIWithSDR + or VideoRangeType.DOVIInvalid); } public static bool IsHdr10Plus(MediaStream stream) @@ -1416,7 +1423,8 @@ namespace MediaBrowser.Controller.MediaEncoding private static DynamicHdrMetadataRemovalPlan ShouldRemoveDynamicHdrMetadata(EncodingJobInfo state) { var videoStream = state.VideoStream; - if (videoStream.VideoRange is not VideoRange.HDR) + if (videoStream.VideoRange is not VideoRange.HDR + && videoStream.VideoRangeType != VideoRangeType.DOVIInvalid) { return DynamicHdrMetadataRemovalPlan.None; } @@ -3799,6 +3807,11 @@ namespace MediaBrowser.Controller.MediaEncoding var formatArg = isFormatFixed ? (":format=" + videoFormat) : string.Empty; var tonemapArg = string.Empty; + // libplacebo only support full range RGB + forceFullRange = forceFullRange + || (videoFormat ?? string.Empty).Contains("rgb", StringComparison.OrdinalIgnoreCase) + || (videoFormat ?? string.Empty).Contains("bgr", StringComparison.OrdinalIgnoreCase); + if (doTonemap) { var algorithm = options.TonemappingAlgorithm; @@ -3822,6 +3835,10 @@ namespace MediaBrowser.Controller.MediaEncoding tonemapArg += ":range=" + range.ToString().ToLowerInvariant(); } } + else if (forceFullRange) + { + formatArg += ":range=pc"; + } return string.Format( CultureInfo.InvariantCulture, @@ -5462,7 +5479,14 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add("format=vaapi"); // clear the surf->meta_offset and output nv12 - mainFilters.Add("scale_vaapi=format=nv12"); + var hwCscFilter = "scale_vaapi=format=nv12"; + + if (!isMjpegEncoder && options.TonemappingRange != TonemappingRange.pc) + { + hwCscFilter += ":out_range=tv"; + } + + mainFilters.Add(hwCscFilter); // hw deint if (doDeintH2645) @@ -5532,7 +5556,14 @@ namespace MediaBrowser.Controller.MediaEncoding overlayFilters.Add("format=vaapi"); // clear the surf->meta_offset and output nv12 - overlayFilters.Add("scale_vaapi=format=nv12"); + var hwCscFilter = "scale_vaapi=format=nv12"; + + if (!doVkTonemap || (doVkTonemap && options.TonemappingRange != TonemappingRange.pc)) + { + hwCscFilter += ":out_range=tv"; + } + + overlayFilters.Add(hwCscFilter); // hw deint if (doDeintH2645) @@ -6311,7 +6342,7 @@ namespace MediaBrowser.Controller.MediaEncoding string.Join(',', overlayFilters)); var mapPrefix = Convert.ToInt32(state.SubtitleStream.IsExternal); - var subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream); + var subtitleStreamIndex = GetSubtitleStreamIndexForFfmpeg(state.MediaSource, state.SubtitleStream); var videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream); if (hasSubs) @@ -7864,10 +7895,16 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state)); } - if (GetAudioEncoder(state).StartsWith("pcm_", StringComparison.Ordinal)) + // The pcm_* encoders emit raw samples that carry no header of their own, so the header + // has to come from the muxer. Only force the matching raw muxer when the client actually + // asked for a raw container (added in #10321 for I2S/MCU clients): applying it to every + // pcm_* codec also strips the RIFF header from a `stream.wav` request, which then serves + // headerless PCM behind an audio/wav content type. + var audioEncoder = GetAudioEncoder(state); + if (audioEncoder.StartsWith("pcm_", StringComparison.Ordinal) + && string.Equals(state.OutputContainer, "pcm", StringComparison.OrdinalIgnoreCase)) { - audioTranscodeParams.Add(string.Concat("-f ", GetAudioEncoder(state).AsSpan(4))); - audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); + audioTranscodeParams.Add(string.Concat("-f ", audioEncoder.AsSpan(4))); } var sampleRate = state.OutputAudioSampleRate; @@ -7937,6 +7974,24 @@ namespace MediaBrowser.Controller.MediaEncoding return -1; } + public static int GetSubtitleStreamIndexForFfmpeg(MediaSourceInfo mediaSource, MediaStream subtitleStream) + { + var index = FindIndex(mediaSource.MediaStreams, subtitleStream); + if (index == -1 || subtitleStream.IsExternal || mediaSource.VideoType != VideoType.BluRay) + { + return index; + } + + var hiddenStreamsBefore = mediaSource.MediaStreams.Count(s => + s.Type == MediaStreamType.Audio + && !s.IsExternal + && (string.Equals(s.Codec, "truehd", StringComparison.OrdinalIgnoreCase) + || string.Equals(s.Codec, "atmos", StringComparison.OrdinalIgnoreCase)) + && s.Index < subtitleStream.Index); + + return index + hiddenStreamsBefore; + } + public static bool IsCopyCodec(string codec) { return string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase); diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs index 56990d0b82..5045030b9b 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs @@ -15,6 +15,7 @@ public sealed class TranscodingJob : IDisposable private readonly Lock _processLock = new(); private readonly Lock _timerLock = new(); + private int _activeRequestCount; private Timer? _killTimer; /// <summary> @@ -64,7 +65,11 @@ public sealed class TranscodingJob : IDisposable /// <summary> /// Gets or sets the active request count. /// </summary> - public int ActiveRequestCount { get; set; } + public int ActiveRequestCount + { + get => Volatile.Read(ref _activeRequestCount); + set => Volatile.Write(ref _activeRequestCount, value); + } /// <summary> /// Gets or sets device id. @@ -152,6 +157,20 @@ public sealed class TranscodingJob : IDisposable public int PingTimeout { get; set; } /// <summary> + /// Increments the active request count. + /// </summary> + /// <returns>The incremented count.</returns> + public int IncrementActiveRequestCount() + => Interlocked.Increment(ref _activeRequestCount); + + /// <summary> + /// Decrements the active request count. + /// </summary> + /// <returns>The decremented count.</returns> + public int DecrementActiveRequestCount() + => Interlocked.Decrement(ref _activeRequestCount); + + /// <summary> /// Stop kill timer. /// </summary> public void StopKillTimer() diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs index ca55340a05..50ed76f762 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs @@ -4,7 +4,9 @@ using MediaBrowser.Model.Session; namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> -/// Force keep alive websocket messages. +/// Force keep alive websocket messages. The data is the timeout in seconds after which the +/// server considers the connection lost; clients are expected to answer with a KeepAlive +/// message and to keep sending one at least every half of that timeout. /// </summary> public class ForceKeepAliveMessage : OutboundWebSocketMessage<int> { diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs index d57f1fc893..14c422cb60 100644 --- a/MediaBrowser.Controller/Persistence/IItemCountService.cs +++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs @@ -37,6 +37,18 @@ public interface IItemCountService ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter); /// <summary> + /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned + /// item value - artists, genres and studios - are answered in one set of queries for the whole + /// batch; the rest fall back to one query per id. + /// </summary> + /// <param name="kind">The kind of the name items.</param> + /// <param name="ids">The IDs of the name items.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="accessFilter">A pre-configured query with user access filtering settings.</param> + /// <returns>The item counts of each requested id.</returns> + Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter); + + /// <summary> /// Gets the count of played items that are descendants of the specified ancestor. /// </summary> /// <param name="filter">The query filter containing user access settings.</param> @@ -80,7 +92,7 @@ public interface IItemCountService /// Batch-fetches child counts for multiple parent folders. /// </summary> /// <param name="parentIds">The list of parent folder IDs.</param> - /// <param name="userId">The user ID for access filtering.</param> + /// <param name="user">The user the counts are for, or null to count without a user's preferences.</param> /// <returns>Dictionary mapping parent ID to child count.</returns> - Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user); } diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs index 2e29cbdbba..f9a050d591 100644 --- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -79,17 +80,26 @@ public interface IItemQueryHelpers Guid ancestorId); /// <summary> - /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played - /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// Builds a query for the playable leaf items a user can access. /// </summary> /// <param name="context">The database context the resulting query is bound to.</param> - /// <param name="folderIds">A query yielding candidate folder IDs.</param> - /// <param name="user">The user for access filtering and played status.</param> - /// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns> - IQueryable<Guid> GetFullyPlayedFolderIdsQuery( + /// <param name="user">The user to filter accessible items for.</param> + /// <param name="includeOwnedItems">Whether to include alternate versions and owned items.</param> + /// <returns>The access-filtered leaf item queryable.</returns> + IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery( JellyfinDbContext context, - IQueryable<Guid> folderIds, - User user); + User user, + bool includeOwnedItems = false); + + /// <summary> + /// Builds a filter matching items that have at least one of <paramref name="descendants"/> below them. + /// </summary> + /// <param name="context">The database context the resulting filter is bound to.</param> + /// <param name="descendants">A query yielding the descendants to look for.</param> + /// <returns>A filter expression matching items with a matching descendant.</returns> + Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter( + JellyfinDbContext context, + IQueryable<BaseItemEntity> descendants); /// <summary> /// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>. diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 291916ab25..3cf06b897c 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -7,6 +7,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Persistence; @@ -120,6 +121,21 @@ public interface IItemRepository IReadOnlyList<string> GetGenreNames(); /// <summary> + /// Gets all language codes of the matching base items and the provided stream type. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <param name="mediaStreamType">The type of the media stream.</param> + /// <returns>List of language codes.</returns> + public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType); + + /// <summary> + /// Gets all distinct tags of the matching base items. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The list of tags.</returns> + IReadOnlyList<string> GetTagNames(InternalItemsQuery filter); + + /// <summary> /// Gets all artist names. /// </summary> /// <returns>The list of artist names.</returns> diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs index a4614fc125..79c29410e4 100644 --- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -20,6 +20,15 @@ public interface ILinkedChildrenService IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null); /// <summary> + /// Gets, in a single query, the subset of the supplied items that own at least one alternate + /// version (local or linked). Items absent from the result have no alternate versions, so their + /// media source count is one. + /// </summary> + /// <param name="itemIds">The item IDs to check.</param> + /// <returns>The set of item IDs that have alternate versions.</returns> + IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds); + + /// <summary> /// Gets all artist matches from the database. /// </summary> /// <param name="artistNames">The names of the artists.</param> diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index e2833dc722..15183a8806 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -34,10 +34,23 @@ public interface IPeopleRepository IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter); /// <summary> + /// Deletes every credit that no item maps to any more. + /// </summary> + /// <returns>The number of credits that were deleted.</returns> + int DeleteOrphanedCredits(); + + /// <summary> /// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table. /// </summary> /// <param name="itemIds">The item IDs to get people for.</param> /// <param name="personTypes">The person types to include (e.g. "Actor", "Director").</param> /// <returns>A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted.</returns> IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes); + + /// <summary> + /// Gets the people for multiple items in a single query, keyed by item id. + /// </summary> + /// <param name="itemIds">The item IDs to get people for.</param> + /// <returns>A dictionary mapping each item ID to its people (with role, type and sort order), ordered by cast list order. Items with no people are omitted.</returns> + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds); } diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index fc367b8293..edf3fb25c9 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -235,18 +235,18 @@ namespace MediaBrowser.Controller.Playlists { if (!IsSharedItem) { - return base.IsVisible(user, skipAllowedTagsCheck); + return base.IsVisible(user, skipAllowedTagsCheck) && HasParentalAllowedChild(user); } if (OpenAccess) { - return true; + return HasParentalAllowedChild(user); } var userId = user.Id; if (userId.Equals(OwnerUserId)) { - return true; + return HasParentalAllowedChild(user); } var shares = Shares; @@ -255,7 +255,19 @@ namespace MediaBrowser.Controller.Playlists return false; } - return shares.Any(s => s.UserId.Equals(userId)); + return shares.Any(s => s.UserId.Equals(userId)) && HasParentalAllowedChild(user); + } + + private bool HasParentalAllowedChild(User user) + { + if (!user.MaxParentalRatingScore.HasValue) + { + return true; + } + + var linkedItems = GetLinkedChildren(); + + return linkedItems.Count == 0 || linkedItems.Any(child => child.IsParentalAllowed(user, true)); } public override bool CanDelete(User user) diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index 6060d051a5..f8e0bf4ed9 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -5,13 +5,19 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using MediaBrowser.Model.IO; namespace MediaBrowser.Controller.Providers { public class DirectoryService : IDirectoryService { - // TODO make static and switch to FastConcurrentLru. + // TODO replace with one shared bounded cache. + private const int MaxCachedRecords = 100_000; + private const int AccessIntervalMs = 1_000; + // Timeout cache if no access for 5 minutes. + private const int IdleTimeoutMs = 5 * 60 * 1_000; + private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new(StringComparer.Ordinal); @@ -20,6 +26,12 @@ namespace MediaBrowser.Controller.Providers private readonly IFileSystem _fileSystem; + // ConcurrentDictionary.Count locks the dictionary, so keep an estimated counter. + // Concurrent factory runs can overcount and a clear racing an add can undercount, + // it only has to be roughly right. + private int _recordCount; + private long _lastAccess = Environment.TickCount64; + public DirectoryService(IFileSystem fileSystem) { _fileSystem = fileSystem; @@ -27,20 +39,26 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata[] GetFileSystemEntries(string path) { + DropCacheIfIdleOrFull(); + return _cache.GetOrAdd( path, - static (p, fileSystem) => + static (p, state) => { + FileSystemMetadata[] entries; try { - return fileSystem.GetFileSystemEntries(p).ToArray(); + entries = state.FileSystem.GetFileSystemEntries(p).ToArray(); } catch (DirectoryNotFoundException) { - return []; + entries = []; } + + Interlocked.Add(ref state.Service._recordCount, entries.Length + 1); + return entries; }, - _fileSystem); + (FileSystem: _fileSystem, Service: this)); } public List<FileSystemMetadata> GetDirectories(string path) @@ -89,13 +107,18 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata? GetFileSystemEntry(string path) { + DropCacheIfIdleOrFull(); + if (!_fileCache.TryGetValue(path, out var result)) { var file = _fileSystem.GetFileSystemInfo(path); if (file?.Exists ?? false) { result = file; - _fileCache.TryAdd(path, result); + if (_fileCache.TryAdd(path, result)) + { + Interlocked.Increment(ref _recordCount); + } } } @@ -107,32 +130,96 @@ namespace MediaBrowser.Controller.Providers public IReadOnlyList<string> GetFilePaths(string path, bool clearCache) { - if (clearCache) + if (clearCache && _filePathCache.TryRemove(path, out var cached)) { - _filePathCache.TryRemove(path, out _); + Interlocked.Add(ref _recordCount, -(cached.Count + 1)); } + DropCacheIfIdleOrFull(); + var filePaths = _filePathCache.GetOrAdd( path, - static (p, fileSystem) => + static (p, state) => { + List<string> filePaths; try { - return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); + filePaths = state.FileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); } catch (DirectoryNotFoundException) { - return []; + filePaths = []; } + + Interlocked.Add(ref state.Service._recordCount, filePaths.Count + 1); + return filePaths; }, - _fileSystem); + (FileSystem: _fileSystem, Service: this)); return filePaths; } + public void Invalidate(string path) + { + Forget(path); + + var parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + { + Forget(parent); + } + } + + public void Move(string source, string destination) + { + Directory.Move(source, destination); + + Invalidate(source); + Invalidate(destination); + } + public bool IsAccessible(string path) { return _fileSystem.GetFileSystemEntryPaths(path).Any(); } + + private void DropCacheIfIdleOrFull() + { + var nowMs = Environment.TickCount64; + var idleMs = nowMs - _lastAccess; + + if (idleMs >= IdleTimeoutMs || _recordCount >= MaxCachedRecords) + { + _cache.Clear(); + _fileCache.Clear(); + _filePathCache.Clear(); + _recordCount = 0; + _lastAccess = nowMs; + return; + } + + if (idleMs >= AccessIntervalMs) + { + _lastAccess = nowMs; + } + } + + private void Forget(string path) + { + if (_cache.TryRemove(path, out var entries)) + { + Interlocked.Add(ref _recordCount, -(entries.Length + 1)); + } + + if (_fileCache.TryRemove(path, out _)) + { + Interlocked.Decrement(ref _recordCount); + } + + if (_filePathCache.TryRemove(path, out var filePaths)) + { + Interlocked.Add(ref _recordCount, -(filePaths.Count + 1)); + } + } } } diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index 8a3fa33da3..3a943d5f0c 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -23,6 +23,19 @@ namespace MediaBrowser.Controller.Providers IReadOnlyList<string> GetFilePaths(string path, bool clearCache); + /// <summary> + /// Forgets what is cached about a path and about the directory containing it. + /// </summary> + /// <param name="path">The file or directory path that changed.</param> + void Invalidate(string path); + + /// <summary> + /// Moves a directory and forgets what is cached about both paths. + /// </summary> + /// <param name="source">The directory to move.</param> + /// <param name="destination">The path to move the directory to.</param> + void Move(string source, string destination); + bool IsAccessible(string path); } } diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index ef69885fcf..48fc22a0fb 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -16,11 +16,6 @@ namespace MediaBrowser.Controller.Providers private List<(string Url, ImageType Type)> _remoteImages; private List<PersonInfo> _people; - public MetadataResult() - { - ResultLanguage = "en"; - } - public List<LocalImageInfo> Images { get => _images ??= []; @@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers public T Item { get; set; } + /// <summary> + /// Gets or sets the language the fetched metadata is in. + /// </summary> public string ResultLanguage { get; set; } public string Provider { get; set; } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index c11c65c334..9acff745b9 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -238,23 +238,26 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Adds the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - void AddAdditionalUser(string sessionId, Guid userId); + void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId); /// <summary> /// Removes the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - void RemoveAdditionalUser(string sessionId, Guid userId); + void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId); /// <summary> /// Reports the now viewing item. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="itemId">The item identifier.</param> - void ReportNowViewingItem(string sessionId, string itemId); + void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId); /// <summary> /// Authenticates the new session. @@ -268,9 +271,10 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Reports the capabilities. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="capabilities">The capabilities.</param> - void ReportCapabilities(string sessionId, ClientCapabilities capabilities); + void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities); /// <summary> /// Reports the transcoding information. diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index fb68bfb770..d9bc79dec5 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -28,6 +28,7 @@ namespace MediaBrowser.Controller.Session private readonly Lock _progressLock = new(); private Timer _progressTimer; private PlaybackProgressInfo _lastProgressInfo; + private long? _lastPlaybackCheckInPositionTicks; private bool _disposed; @@ -125,6 +126,22 @@ namespace MediaBrowser.Controller.Session public DateTime LastPlaybackCheckIn { get; set; } /// <summary> + /// Gets the position reported by the client at the last playback check-in. + /// </summary> + /// <value>The position ticks, or <see langword="null"/> if the client did not report a position.</value> + [JsonIgnore] + public long? LastPlaybackCheckInPositionTicks + { + get + { + lock (_progressLock) + { + return _lastPlaybackCheckInPositionTicks; + } + } + } + + /// <summary> /// Gets or sets the last paused date. /// </summary> /// <value>The last paused date.</value> @@ -372,6 +389,7 @@ namespace MediaBrowser.Controller.Session lock (_progressLock) { + _lastPlaybackCheckInPositionTicks = progressInfo.PositionTicks; _lastProgressInfo = progressInfo; if (_progressTimer is null) diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs index eb38eeb503..8f17039ae1 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -50,6 +50,11 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates /// </summary> private GroupStateType InitialState { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the group position moved during this wait. + /// </summary> + private bool PositionJumped { get; set; } + /// <inheritdoc /> public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) { @@ -136,6 +141,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates ResumePlaying = true; var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks); + PositionJumped = setQueueStatus; if (!setQueueStatus) { _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString()); @@ -175,6 +181,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates ResumePlaying = true; var result = context.SetPlayingItem(request.PlaylistItemId); + PositionJumped = result; if (result) { var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); @@ -214,6 +221,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { ResumePlaying = true; context.RestartCurrentItem(); + PositionJumped = true; var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); @@ -310,6 +318,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates // Seek. context.PositionTicks = ticks; context.LastActivity = DateTime.UtcNow; + PositionJumped = true; var command = context.NewSyncPlayCommand(SendCommandType.Seek); context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); @@ -450,7 +459,13 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Handle case where session reported as ready but in reality // it has no clue of the real position nor the playback state. - if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks) + // A jump means the session has not applied the new position; without one it is + // catching up after buffering and is allowed to lag. + var maxOffsetTicks = request.IsPlaying && !PositionJumped + ? TimeSpan.FromMilliseconds(context.MaxCatchUpOffset).Ticks + : maxPlaybackOffsetTicks; + + if (Math.Abs(delayTicks) > maxOffsetTicks) { // Session not ready at all. context.SetBuffering(session, true); @@ -501,7 +516,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Client, that was buffering, resumed playback but did not update others in time. delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond; - delayTicks = Math.Max(delayTicks, context.DefaultPing); + delayTicks = Math.Max(delayTicks, TimeSpan.FromMilliseconds(context.DefaultPing).Ticks); context.LastActivity = currentTime.AddTicks(delayTicks); @@ -580,6 +595,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var newItem = context.NextItemInQueue(); + PositionJumped = newItem; if (newItem) { // Send playing-queue update. @@ -626,6 +642,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var newItem = context.PreviousItemInQueue(); + PositionJumped = newItem; if (newItem) { // Send playing-queue update. diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs index ddf86be71f..e02d1bde45 100644 --- a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs +++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs @@ -34,6 +34,12 @@ namespace MediaBrowser.Controller.SyncPlay long MaxPlaybackOffset { get; } /// <summary> + /// Gets the maximum offset accepted for a session catching up after buffering, in milliseconds. + /// </summary> + /// <value>The maximum catch-up offset, in milliseconds.</value> + long MaxCatchUpOffset => 60000; + + /// <summary> /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs index c0a168192e..258b92e4d9 100644 --- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -157,7 +157,10 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// </summary> public void RestoreSortedPlaylist() { - if (PlayingItemIndex != NoPlayingItemIndex) + // The shuffled playlist is only populated while the shuffle mode is active, so there is + // nothing to map back when the playlist is already sorted. Guarding on its contents keeps + // a redundant request for the sorted mode from indexing an empty list. + if (PlayingItemIndex != NoPlayingItemIndex && _shuffledPlaylist.Count > 0) { var playingItem = _shuffledPlaylist[PlayingItemIndex]; PlayingItemIndex = _sortedPlaylist.IndexOf(playingItem); @@ -272,7 +275,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue public void SetPlayingItemByIndex(int playlistIndex) { var playlist = GetPlaylistInternal(); - if (playlistIndex < 0 || playlistIndex > playlist.Count) + if (playlistIndex < 0 || playlistIndex >= playlist.Count) { PlayingItemIndex = NoPlayingItemIndex; } @@ -293,6 +296,15 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { var playingItem = GetPlayingItem(); + // Removed items that precede the playing item shift its index as well. + var removedBeforePlayingItem = 0; + if (playingItem is not null) + { + removedBeforePlayingItem = GetPlaylistInternal() + .Take(PlayingItemIndex) + .Count(item => playlistItemIds.Contains(item.PlaylistItemId)); + } + _sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); _shuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); @@ -303,12 +315,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue if (playlistItemIds.Contains(playingItem.PlaylistItemId)) { // Playing item has been removed, picking previous item. - PlayingItemIndex--; + PlayingItemIndex -= removedBeforePlayingItem + 1; if (PlayingItemIndex < 0) { // Was first element, picking next if available. // Default to no playing item otherwise. - PlayingItemIndex = _sortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex; + PlayingItemIndex = GetPlaylistInternal().Count > 0 ? 0 : NoPlayingItemIndex; } return true; @@ -444,6 +456,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns> public bool Next() { + if (GetPlaylistInternal().Count == 0) + { + return false; + } + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) { LastChange = DateTime.UtcNow; @@ -474,6 +491,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns> public bool Previous() { + if (GetPlaylistInternal().Count == 0) + { + return false; + } + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) { LastChange = DateTime.UtcNow; |
