aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.Controller/Entities
diff options
context:
space:
mode:
Diffstat (limited to 'MediaBrowser.Controller/Entities')
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs317
-rw-r--r--MediaBrowser.Controller/Entities/Book.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs55
-rw-r--r--MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs5
-rw-r--r--MediaBrowser.Controller/Entities/IHasStartDate.cs8
-rw-r--r--MediaBrowser.Controller/Entities/IItemByName.cs15
-rw-r--r--MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs5
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs103
-rw-r--r--MediaBrowser.Controller/Entities/InternalPeopleQuery.cs14
-rw-r--r--MediaBrowser.Controller/Entities/Movies/Movie.cs2
-rw-r--r--MediaBrowser.Controller/Entities/MusicVideo.cs2
-rw-r--r--MediaBrowser.Controller/Entities/PeopleHelper.cs74
-rw-r--r--MediaBrowser.Controller/Entities/Person.cs11
-rw-r--r--MediaBrowser.Controller/Entities/SourceType.cs16
-rw-r--r--MediaBrowser.Controller/Entities/TV/Episode.cs2
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs45
-rw-r--r--MediaBrowser.Controller/Entities/Trailer.cs2
-rw-r--r--MediaBrowser.Controller/Entities/UserRootFolder.cs8
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs62
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs266
20 files changed, 850 insertions, 167 deletions
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 21304768bd..28f40cb7fa 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;
@@ -87,6 +88,8 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
+ private protected static readonly char[] VersionDelimiters = ['-', '_', '.'];
+
private string _sortName;
private string _forcedSortName;
@@ -538,8 +541,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
{
@@ -768,6 +771,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;
@@ -924,19 +938,31 @@ namespace MediaBrowser.Controller.Entities
/// <returns>System.String.</returns>
protected virtual string CreateSortName()
{
- if (Name is null)
+ return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
+ }
+
+ /// <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))
@@ -954,12 +980,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);
}
@@ -1099,8 +1125,9 @@ namespace MediaBrowser.Controller.Entities
}
}
- var list = GetAllItemsForMediaSources();
- var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList();
+ var list = GetAllItemsForMediaSources().ToList();
+ var commonPrefix = GetCommonNamePrefix(list);
+ var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList();
if (IsActiveRecording())
{
@@ -1110,17 +1137,15 @@ namespace MediaBrowser.Controller.Entities
}
}
- return result.OrderBy(i =>
- {
- if (i.VideoType == VideoType.VideoFile)
- {
- return 0;
- }
+ // The source belonging to the item being queried sorts first so it is the default the client plays.
+ var selfId = Id.ToString("N", CultureInfo.InvariantCulture);
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i => i, new MediaSourceWidthComparator())
- .ToArray();
+ return result
+ .OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1)
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i => i, new MediaSourceWidthComparator())
+ .ToArray();
}
protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
@@ -1128,7 +1153,7 @@ namespace MediaBrowser.Controller.Entities
return Enumerable.Empty<(BaseItem, MediaSourceType)>();
}
- private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type)
+ private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null)
{
ArgumentNullException.ThrowIfNull(item);
@@ -1141,7 +1166,7 @@ namespace MediaBrowser.Controller.Entities
Protocol = protocol ?? MediaProtocol.File,
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
- Name = GetMediaSourceName(item),
+ Name = GetMediaSourceName(item, commonPrefix),
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
@@ -1220,7 +1245,7 @@ namespace MediaBrowser.Controller.Entities
return info;
}
- internal string GetMediaSourceName(BaseItem item)
+ internal string GetMediaSourceName(BaseItem item, string commonPrefix = null)
{
var terms = new List<string>();
@@ -1228,12 +1253,31 @@ namespace MediaBrowser.Controller.Entities
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
{
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
- if (HasLocalAlternateVersions)
+
+ // Prefer the suffix that differs from the other versions: strip the prefix shared by
+ // all sibling files. This works regardless of folder layout, so it also labels episode
+ // versions that share a season folder (e.g. "Greyscale" instead of the full
+ // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter
+ // boundary (see GetCommonVersionPrefix).
+ if (!string.IsNullOrEmpty(commonPrefix)
+ && displayName.Length > commonPrefix.Length
+ && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]);
+ if (!name.IsWhiteSpace())
+ {
+ terms.Add(name.ToString());
+ }
+ }
+
+ // Fall back to the containing folder name (the common layout for movie versions, and
+ // the path taken when no common prefix could be derived).
+ if (terms.Count == 0 && HasLocalAlternateVersions)
{
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
{
- var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
+ var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1290,6 +1334,98 @@ namespace MediaBrowser.Controller.Entities
return string.Join('/', terms);
}
+ /// <summary>
+ /// Derives the prefix shared by the supplied media source items' file names, used to strip the
+ /// common part and surface a short version label per source. Returns null when there are fewer
+ /// than two file-based sources, since there is nothing to differentiate.
+ /// </summary>
+ /// <param name="items">The media source items.</param>
+ /// <returns>The shared prefix, or null when no useful prefix exists.</returns>
+ private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items)
+ {
+ var fileNames = new List<string>();
+ foreach (var (item, _) in items)
+ {
+ if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path))
+ {
+ fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path));
+ }
+ }
+
+ if (fileNames.Count < 2)
+ {
+ return null;
+ }
+
+ var prefix = GetCommonVersionPrefix(fileNames);
+ return string.IsNullOrEmpty(prefix) ? null : prefix;
+ }
+
+ /// <summary>
+ /// Computes the case-insensitive longest common prefix of the supplied version file names,
+ /// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact:
+ /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
+ /// "Greyish") while still trimming the common part when every version is suffixed (e.g.
+ /// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a
+ /// 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).
+ /// </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>
+ internal static string GetCommonVersionPrefix(IReadOnlyList<string> fileNames)
+ {
+ var prefix = fileNames[0];
+ for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++)
+ {
+ var name = fileNames[i];
+ var length = Math.Min(prefix.Length, name.Length);
+ var common = 0;
+ while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common]))
+ {
+ common++;
+ }
+
+ prefix = prefix[..common];
+ }
+
+ // If the common prefix is itself a whole file name then one version is unlabelled (the
+ // base name); the boundary already sits at the end of that name, so don't retreat into it.
+ var prefixIsWholeName = false;
+ for (var i = 0; i < fileNames.Count; i++)
+ {
+ if (fileNames[i].Length == prefix.Length)
+ {
+ prefixIsWholeName = true;
+ break;
+ }
+ }
+
+ if (!prefixIsWholeName)
+ {
+ // Retreat to the last structural delimiter ('-', '_', '.').
+ var cut = prefix.Length;
+ while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0)
+ {
+ cut--;
+ }
+
+ if (cut == 0)
+ {
+ cut = prefix.Length;
+ while (cut > 0 && prefix[cut - 1] != ' ')
+ {
+ cut--;
+ }
+ }
+
+ prefix = prefix[..cut];
+ }
+
+ return prefix;
+ }
+
public Task RefreshMetadata(CancellationToken cancellationToken)
{
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
@@ -1403,7 +1539,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;
}
@@ -1418,33 +1561,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);
});
@@ -2011,12 +2180,23 @@ namespace MediaBrowser.Controller.Entities
// I think it is okay to do this here.
// if this is only called when a user is manually forcing something to un-played
// then it probably is what we want to do...
+ ResetPlayedState(data);
+
+ UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Clears the played state on the supplied user data.
+ /// </summary>
+ /// <param name="data">The user data to reset.</param>
+ protected static void ResetPlayedState(UserItemData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
data.PlayCount = 0;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = null;
data.Played = false;
-
- UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
}
/// <summary>
@@ -2516,6 +2696,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)
@@ -2575,6 +2781,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);
@@ -2732,6 +2943,34 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
+ /// Gets the ids of the items whose owned extras belong to this item.
+ /// </summary>
+ /// <returns>An array containing the owner ids.</returns>
+ protected virtual Guid[] GetExtraOwnerIds()
+ {
+ return [Id];
+ }
+
+ /// <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>
@@ -2740,7 +2979,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
@@ -2755,7 +2994,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
ExtraTypes = extraTypes.ToArray(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
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 25cbcedc5f..d8203ea6f2 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; }
@@ -384,6 +400,7 @@ namespace MediaBrowser.Controller.Entities
cancellationToken.ThrowIfCancellationRequested();
var validChildren = new List<BaseItem>();
+ var accessibleChildren = new List<BaseItem>();
var validChildrenNeedGeneration = false;
if (IsFileProtocol)
@@ -438,12 +455,19 @@ namespace MediaBrowser.Controller.Entities
{
if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot))
{
+ // Preserve inaccessible items so they aren't treated as removed.
+ if (currentChildren.TryGetValue(child.Id, out var childrenToKeep))
+ {
+ validChildren.Add(childrenToKeep);
+ }
+
continue;
}
if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild))
{
validChildren.Add(currentChild);
+ accessibleChildren.Add(currentChild);
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
{
@@ -480,11 +504,12 @@ namespace MediaBrowser.Controller.Entities
child.SetParent(this);
newItems.Add(child);
validChildren.Add(child);
+ accessibleChildren.Add(child);
}
// That's all the new and changed ones - now see if any have been removed and need cleanup
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
- var shouldRemove = !IsRoot || allowRemoveRoot;
+
// If it's an AggregateFolder, don't remove
// Collect replaced primaries for deferred deletion (after CreateItems)
var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
@@ -497,7 +522,7 @@ namespace MediaBrowser.Controller.Entities
.Where(p => !string.IsNullOrEmpty(p))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- if (shouldRemove && itemsRemoved.Count > 0)
+ if (itemsRemoved.Count > 0)
{
foreach (var item in itemsRemoved)
{
@@ -703,7 +728,7 @@ namespace MediaBrowser.Controller.Entities
validChildrenNeedGeneration = false;
}
- await ValidateSubFolders(validChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
+ await ValidateSubFolders(accessibleChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
}
if (refreshChildMetadata)
@@ -742,7 +767,7 @@ namespace MediaBrowser.Controller.Entities
validChildren = Children.ToList();
}
- await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
+ await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -1076,15 +1101,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)
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 422c40ce5d..e85f86b72f 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -72,6 +72,102 @@ namespace MediaBrowser.Controller.Entities
}
}
+ /// <summary>
+ /// Gets a value indicating whether the query carries any criteria that narrows the
+ /// result set, as opposed to user context, pagination, sorting or DTO options.
+ /// </summary>
+ public bool HasFilters =>
+ IncludeItemTypes.Length > 0
+ || ExcludeItemTypes.Length > 0
+ || Genres.Count > 0
+ || GenreIds.Count > 0
+ || Years.Length > 0
+ || Tags.Length > 0
+ || ExcludeTags.Length > 0
+ || OfficialRatings.Length > 0
+ || StudioIds.Length > 0
+ || ArtistIds.Length > 0
+ || AlbumArtistIds.Length > 0
+ || ContributingArtistIds.Length > 0
+ || ExcludeArtistIds.Length > 0
+ || AlbumIds.Length > 0
+ || PersonIds.Length > 0
+ || PersonTypes.Length > 0
+ || MediaTypes.Length > 0
+ || VideoTypes.Length > 0
+ || ImageTypes.Length > 0
+ || SeriesStatuses.Length > 0
+ || ItemIds.Length > 0
+ || ExcludeItemIds.Length > 0
+ || AudioLanguages.Count > 0
+ || SubtitleLanguages.Count > 0
+ || LinkedChildAncestorIds.Length > 0
+ || AncestorIds.Length > 0
+ || IsFavorite.HasValue
+ || IsFavoriteOrLiked.HasValue
+ || IsLiked.HasValue
+ || IsPlayed.HasValue
+ || IsResumable.HasValue
+ || IsFolder.HasValue
+ || IsMissing.HasValue
+ || IsUnaired.HasValue
+ || IsSpecialSeason.HasValue
+ || Is3D.HasValue
+ || IsHD.HasValue
+ || Is4K.HasValue
+ || IsLocked.HasValue
+ || IsPlaceHolder.HasValue
+ || IsMovie.HasValue
+ || IsSports.HasValue
+ || IsKids.HasValue
+ || IsNews.HasValue
+ || IsSeries.HasValue
+ || IsAiring.HasValue
+ || IsVirtualItem.HasValue
+ || HasImdbId.HasValue
+ || HasTmdbId.HasValue
+ || HasTvdbId.HasValue
+ || HasOverview.HasValue
+ || HasOfficialRating.HasValue
+ || HasParentalRating.HasValue
+ || HasThemeSong.HasValue
+ || HasThemeVideo.HasValue
+ || HasSubtitles.HasValue
+ || HasSpecialFeature.HasValue
+ || HasTrailer.HasValue
+ || HasChapterImages.HasValue
+ || MinCriticRating.HasValue
+ || MinCommunityRating.HasValue
+ || MinParentalRating is not null
+ || MinIndexNumber.HasValue
+ || MinParentAndIndexNumber.HasValue
+ || IndexNumber.HasValue
+ || ParentIndexNumber.HasValue
+ || AiredDuringSeason.HasValue
+ || MinWidth.HasValue
+ || MinHeight.HasValue
+ || MaxWidth.HasValue
+ || MaxHeight.HasValue
+ || MinPremiereDate.HasValue
+ || MaxPremiereDate.HasValue
+ || MinStartDate.HasValue
+ || MaxStartDate.HasValue
+ || MinEndDate.HasValue
+ || MaxEndDate.HasValue
+ || MinDateCreated.HasValue
+ || MinDateLastSaved.HasValue
+ || MinDateLastSavedForUser.HasValue
+ || AdjacentTo.HasValue
+ || !string.IsNullOrEmpty(NameStartsWith)
+ || !string.IsNullOrEmpty(NameStartsWithOrGreater)
+ || !string.IsNullOrEmpty(NameLessThan)
+ || !string.IsNullOrEmpty(NameContains)
+ || !string.IsNullOrEmpty(MinSortName)
+ || !string.IsNullOrEmpty(Name)
+ || !string.IsNullOrEmpty(Person)
+ || !string.IsNullOrEmpty(SearchTerm)
+ || !string.IsNullOrEmpty(Path);
+
public bool Recursive { get; set; }
public int? StartIndex { get; set; }
@@ -400,6 +496,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;
@@ -423,6 +525,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/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..14325d971a 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;
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/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..1a1da84b7a 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));
@@ -507,7 +540,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/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs
index deed3631b8..d5be997b84 100644
--- a/MediaBrowser.Controller/Entities/UserRootFolder.cs
+++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs
@@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities
protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
{
- if (query.Recursive)
+ // The user root holds no items of its own - a plain listing returns the user's
+ // views. But a request carrying any filter is a search across the libraries, so
+ // resolve it through the recursive query path even when Recursive wasn't set;
+ // otherwise the filters would be silently dropped. Recursive is set so the
+ // downstream query (ancestor/top-parent scoping) treats it as a recursive search.
+ if (query.Recursive || query.HasFilters)
{
+ query.Recursive = true;
return QueryRecursive(query);
}
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index cb05056601..f9ad2d86e6 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities
case CollectionType.folders:
return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query);
+ case CollectionType.books:
+ return GetBooks(queryParent, user, query);
+
case CollectionType.tvshows:
return GetTvView(queryParent, user, query);
@@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities
return _libraryManager.GetItemsResult(query);
}
+ private QueryResult<BaseItem> GetBooks(Folder parent, User user, InternalItemsQuery query)
+ {
+ query.Recursive = true;
+ query.Parent = parent;
+ query.SetUser(user);
+
+ query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
+
+ return _libraryManager.GetItemsResult(query);
+ }
+
private QueryResult<BaseItem> GetMovieMovies(Folder parent, User user, InternalItemsQuery query)
{
query.Recursive = true;
@@ -447,11 +461,12 @@ namespace MediaBrowser.Controller.Entities
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
var isPlayedValue = query.IsPlayed.Value;
- return itemList.Where(i =>
+ return itemList.Where(item =>
{
- if (i.IsFolder && counts.TryGetValue(i.Id, out var c))
+ if (item is Folder)
{
- return (c.Total > 0 && c.Played == c.Total) == isPlayedValue;
+ var itemCount = counts.GetValueOrDefault(item.Id);
+ return (itemCount.Played >= itemCount.Total) == isPlayedValue;
}
return true;
@@ -476,6 +491,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)
@@ -716,7 +738,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;
}
@@ -872,26 +894,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 e7a5672ebd..e2f91aa04a 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -34,11 +34,11 @@ namespace MediaBrowser.Controller.Entities
{
public Video()
{
- AdditionalParts = Array.Empty<string>();
- LocalAlternateVersions = Array.Empty<string>();
- SubtitleFiles = Array.Empty<string>();
- AudioFiles = Array.Empty<string>();
- LinkedAlternateVersions = Array.Empty<LinkedChild>();
+ AdditionalParts = [];
+ LocalAlternateVersions = [];
+ SubtitleFiles = [];
+ AudioFiles = [];
+ LinkedAlternateVersions = [];
}
[JsonIgnore]
@@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities
private int GetMediaSourceCount(HashSet<Guid> callstack = null)
{
- callstack ??= new();
+ callstack ??= [];
if (PrimaryVersionId.HasValue)
{
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
@@ -335,6 +335,102 @@ namespace MediaBrowser.Controller.Entities
PresentationUniqueKey = CreatePresentationUniqueKey();
}
+ /// <summary>
+ /// Marks the played status of this video and propagates it to its alternate versions.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="datePlayed">The date played.</param>
+ /// <param name="resetPosition">if set to <c>true</c> [reset position].</param>
+ public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
+ {
+ base.MarkPlayed(user, datePlayed, resetPosition);
+ PropagatePlayedState(user, true, resetPosition);
+ }
+
+ /// <summary>
+ /// Marks this video unplayed and propagates the change to its alternate versions.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ public override void MarkUnplayed(User user)
+ {
+ base.MarkUnplayed(user);
+
+ // MarkUnplayed always clears the position on this video, so reset the versions too.
+ PropagatePlayedState(user, false, true);
+ }
+
+ /// <summary>
+ /// Propagates the played status to every alternate version of this video.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="played">The played status to apply to the alternate versions.</param>
+ /// <param name="resetPosition">When marking played, controls whether each version's resume point
+ /// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed,
+ /// which always fully resets every version.</param>
+ public void PropagatePlayedState(User user, bool played, bool resetPosition = true)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions)
+ {
+ return;
+ }
+
+ foreach (var (item, _) in GetAllItemsForMediaSources())
+ {
+ if (item.Id.Equals(Id) || item is not Video)
+ {
+ continue;
+ }
+
+ if (played)
+ {
+ var dto = new UpdateUserItemDataDto { Played = true };
+ if (resetPosition)
+ {
+ dto.PlaybackPositionTicks = 0;
+ }
+
+ // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved.
+ UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed);
+ }
+ else
+ {
+ var data = UserDataManager.GetUserData(user, item);
+ if (data is null)
+ {
+ continue;
+ }
+
+ ResetPlayedState(data);
+ UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets this video together with all of its alternate versions (local and linked and, when this
+ /// is itself an alternate, the primary and the primary's other versions), deduplicated.
+ /// </summary>
+ /// <returns>This video and every alternate version of it.</returns>
+ public IReadOnlyList<Video> GetAllVersions()
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item)
+ .OfType<Video>()
+ .ToList();
+ }
+
+ /// <summary>
+ /// Gets the alternate version of this video that matches the supplied item id.
+ /// </summary>
+ /// <param name="itemId">The version item id (the playback media source id).</param>
+ /// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns>
+ public Video GetAlternateVersion(Guid itemId)
+ {
+ return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId));
+ }
+
public override string CreatePresentationUniqueKey()
{
if (PrimaryVersionId.HasValue)
@@ -396,8 +492,8 @@ namespace MediaBrowser.Controller.Entities
public IOrderedEnumerable<Video> GetAdditionalParts(User user = null)
{
return GetAdditionalPartIds()
- .Select(i => LibraryManager.GetItemById<Video>(i, user))
- .Where(i => i is not null)
+ .Select(i => LibraryManager.GetItemById<Video>(i))
+ .Where(i => i is not null && (user is null || i.IsVisible(user)))
.OrderBy(i => i.SortName);
}
@@ -431,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)
@@ -492,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
@@ -575,6 +687,7 @@ namespace MediaBrowser.Controller.Entities
video.Id = id;
video.OwnerId = Id;
+ video.IsInMixedFolder = IsInMixedFolder;
LibraryManager.CreateItem(video, parentFolder);
newOptions.ForceSave = true;
}
@@ -642,39 +755,132 @@ namespace MediaBrowser.Controller.Entities
}).FirstOrDefault();
}
- protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
+ /// <summary>
+ /// Gets the ids of the items whose owned extras belong to this item.
+ /// Extras are linked to a single version but need tp be surfaced for all versions.
+ /// </summary>
+ /// <returns>An array containing the owner ids.</returns>
+ protected override Guid[] GetExtraOwnerIds()
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item.Id)
+ .Distinct()
+ .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)
{
- var list = new List<(BaseItem, MediaSourceType)>
+ if (string.IsNullOrEmpty(extra.Path))
{
- (this, MediaSourceType.Default)
- };
+ return Id;
+ }
- list.AddRange(
- LibraryManager.GetLinkedAlternateVersions(this)
- .Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
+ var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan());
+ var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan());
- if (PrimaryVersionId.HasValue)
+ var ownerId = Id;
+ var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName);
+
+ foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this))
{
- if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
+ var version = LibraryManager.GetItemById(versionId);
+ if (version is null)
{
- var existingIds = list.Select(i => i.Item1.Id).ToList();
- list.Add((primary, MediaSourceType.Grouping));
- list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
+ continue;
}
- }
- var localAlternates = list
- .SelectMany(i =>
+ // "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)
{
- return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
- })
- .Select(LibraryManager.GetItemById)
- .Where(i => i is not null)
+ 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
+ ? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
+ : null;
+
+ var primaryLinked = primary is null
+ ? []
+ : LibraryManager.GetLinkedAlternateVersions(primary).ToList();
+
+ // Grouping marks user-merged (splittable) sources. The primary is only such a source when
+ // this video is linked onto it; for local (file-based) alternates the primary is just
+ // another default source.
+ var primaryType = primaryLinked.Any(i => i.Id.Equals(Id))
+ ? MediaSourceType.Grouping
+ : MediaSourceType.Default;
+
+ // This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates.
+ var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) }
+ .Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
+ .Concat(primary is null
+ ? []
+ : primaryLinked.Select(i => ((BaseItem)i, MediaSourceType.Grouping)).Prepend(((BaseItem)primary, primaryType)))
.ToList();
- list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default)));
+ // The local (file-based) alternate versions of every grouped item.
+ var localAlternates = grouped
+ .Select(i => i.Item1)
+ .OfType<Video>()
+ .SelectMany(LibraryManager.GetLocalAlternateVersionIds)
+ .Select(LibraryManager.GetItemById)
+ .Where(i => i is not null)
+ .Select(i => (i, MediaSourceType.Default));
- return list;
+ // Deduplicate
+ return grouped
+ .Concat(localAlternates)
+ .DistinctBy(i => i.Item1.Id)
+ .ToList();
}
}
}