aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.Controller
diff options
context:
space:
mode:
Diffstat (limited to 'MediaBrowser.Controller')
-rw-r--r--MediaBrowser.Controller/Dto/DtoOptions.cs14
-rw-r--r--MediaBrowser.Controller/Entities/AggregateFolder.cs18
-rw-r--r--MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs12
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs35
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs112
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs22
-rw-r--r--MediaBrowser.Controller/Entities/Movies/BoxSet.cs37
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs13
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs51
-rw-r--r--MediaBrowser.Controller/IO/FileSystemHelper.cs11
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs43
-rw-r--r--MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs152
-rw-r--r--MediaBrowser.Controller/LiveTv/ILiveTvManager.cs7
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj3
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs65
-rw-r--r--MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs4
-rw-r--r--MediaBrowser.Controller/Persistence/IItemCountService.cs12
-rw-r--r--MediaBrowser.Controller/Persistence/IItemRepository.cs7
-rw-r--r--MediaBrowser.Controller/Playlists/Playlist.cs20
-rw-r--r--MediaBrowser.Controller/Providers/DirectoryService.cs111
-rw-r--r--MediaBrowser.Controller/Providers/IDirectoryService.cs13
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs21
-rw-r--r--MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs6
-rw-r--r--MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs5
24 files changed, 669 insertions, 125 deletions
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/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index d030c8f420..70e7da8932 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -1411,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>
@@ -1445,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--;
}
@@ -1467,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);
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index d8203ea6f2..626bc0d5a1 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -286,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);
@@ -316,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)
{
@@ -370,6 +391,9 @@ namespace MediaBrowser.Controller.Entities
{
ProviderManager.OnRefreshComplete(this);
}
+
+ // The subtree is done with, so stop holding it.
+ ReleaseCachedChildren();
}
}
@@ -405,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)
{
@@ -551,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 ?? [])
@@ -593,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);
@@ -621,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))
@@ -633,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)));
@@ -653,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();
@@ -666,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);
}
}
@@ -772,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(
@@ -807,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();
+ }
}
}
}
@@ -876,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)
@@ -1613,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)
{
@@ -1730,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)
@@ -1753,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/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index e85f86b72f..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; }
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/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index 1a1da84b7a..126f4361ba 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -333,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();
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index f9ad2d86e6..82256cd964 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -455,25 +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(item =>
+ return itemList.Where(item =>
+ {
+ if (item is Folder)
{
- if (item is Folder)
- {
- var itemCount = counts.GetValueOrDefault(item.Id);
- return (itemCount.Played >= itemCount.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;
@@ -606,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)
{
diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs
index f636258191..b2d2273cbe 100644
--- a/MediaBrowser.Controller/IO/FileSystemHelper.cs
+++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs
@@ -193,8 +193,13 @@ public static class FileSystemHelper
var fullParentPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parentPath));
// Catches the remaining relative names, "." and "..", which are valid single segments.
- return string.Equals(Path.GetDirectoryName(fullPath), fullParentPath, StringComparison.Ordinal)
- ? fullPath
- : null;
+ 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 c8cca1fa93..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>
@@ -709,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);
@@ -754,6 +760,18 @@ 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>
@@ -815,6 +833,13 @@ 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>
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 10c21ee03c..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"
};
}
@@ -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)
@@ -7943,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/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 8ddf93e3e0..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>
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index d44fe57bed..3cf06b897c 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -129,6 +129,13 @@ public interface IItemRepository
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/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/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 9326864d78..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);