diff options
Diffstat (limited to 'MediaBrowser.Controller')
94 files changed, 3580 insertions, 1116 deletions
diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs index ca7721991d..9557c91964 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs @@ -1,19 +1,29 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The result of a channel item query. + /// </summary> public class ChannelItemResult { + /// <summary> + /// Initializes a new instance of the <see cref="ChannelItemResult"/> class. + /// </summary> public ChannelItemResult() { Items = Array.Empty<ChannelItemInfo>(); } + /// <summary> + /// Gets or sets the items. + /// </summary> public IReadOnlyList<ChannelItemInfo> Items { get; set; } + /// <summary> + /// Gets or sets the total record count. + /// </summary> public int? TotalRecordCount { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelItemType.cs b/MediaBrowser.Controller/Channels/ChannelItemType.cs index 3ce920e236..2608cb4c88 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemType.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemType.cs @@ -1,11 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The type of a channel item. + /// </summary> public enum ChannelItemType { + /// <summary> + /// The item is a media item. + /// </summary> Media = 0, + /// <summary> + /// The item is a folder. + /// </summary> Folder = 1 } } diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs index ebbe13763b..c6530814b9 100644 --- a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs +++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs @@ -1,11 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The request for a latest media search in a channel. + /// </summary> public class ChannelLatestMediaSearch { + /// <summary> + /// Gets or sets the user id. + /// </summary> public string UserId { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs index f77d81c166..a5a1ba5bf6 100644 --- a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs +++ b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs @@ -1,17 +1,33 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The parental rating of a channel. + /// </summary> public enum ChannelParentalRating { + /// <summary> + /// Suitable for a general audience. + /// </summary> GeneralAudience = 0, + /// <summary> + /// Parental guidance suggested (US PG). + /// </summary> UsPG = 1, + /// <summary> + /// Parents strongly cautioned (US PG-13). + /// </summary> UsPG13 = 2, + /// <summary> + /// Restricted (US R). + /// </summary> UsR = 3, + /// <summary> + /// Suitable for adults only. + /// </summary> Adult = 4 } } diff --git a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs index 990b025bcb..d172b98b25 100644 --- a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs +++ b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs @@ -1,13 +1,20 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The request for a search in a channel. + /// </summary> public class ChannelSearchInfo { + /// <summary> + /// Gets or sets the search term. + /// </summary> public string SearchTerm { get; set; } + /// <summary> + /// Gets or sets the user id. + /// </summary> public string UserId { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs index 7d5207c34a..4cdda38bd9 100644 --- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs +++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs @@ -1,14 +1,15 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// Interface for channels that provide a cache key. + /// </summary> public interface IHasCacheKey { /// <summary> /// Gets the cache key. /// </summary> /// <param name="userId">The user identifier.</param> - /// <returns>System.String.</returns> + /// <returns>The cache key.</returns> string? GetCacheKey(string? userId); } } diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs index 0110bfa7a3..194654ca9e 100644 --- a/MediaBrowser.Controller/Channels/ISupportsDelete.cs +++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs @@ -1,15 +1,27 @@ -#pragma warning disable CS1591 - using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Channels { + /// <summary> + /// Interface for channels that support deleting items. + /// </summary> public interface ISupportsDelete { + /// <summary> + /// Gets a value indicating whether the item can be deleted. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if the item can be deleted, <c>false</c> otherwise.</returns> bool CanDelete(BaseItem item); + /// <summary> + /// Deletes the item with the provided id. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the deletion of the item.</returns> Task DeleteItem(string id, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs index 1935ec0f5f..82ca45d3ad 100644 --- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs +++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs @@ -1,11 +1,12 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace MediaBrowser.Controller.Channels { + /// <summary> + /// Interface for channels that support retrieving the latest media. + /// </summary> public interface ISupportsLatestMedia { /// <summary> diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index 25656fd625..edc20205aa 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -14,11 +14,18 @@ namespace MediaBrowser.Controller.Chapters; public interface IChapterManager { /// <summary> + /// Gets a value indicating whether the specified item type is supported for chapter operations. + /// </summary> + /// <param name="item">The item to check.</param> + /// <returns><c>true</c> if the item type supports chapters; otherwise, <c>false</c>.</returns> + bool Supports(BaseItem item); + + /// <summary> /// Saves the chapters. /// </summary> - /// <param name="video">The video.</param> + /// <param name="item">The item.</param> /// <param name="chapters">The set of chapters.</param> - void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters); + void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters); /// <summary> /// Gets a single chapter of a BaseItem on a specific index. diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index 206b5ac426..8d5d54ffd9 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -58,6 +58,14 @@ namespace MediaBrowser.Controller.Collections IEnumerable<BaseItem> CollapseItemsWithinBoxSets(IEnumerable<BaseItem> items, User user); /// <summary> + /// Gets the collections accessible to the supplied user that contain the provided item. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="itemId">The item identifier.</param> + /// <returns>The collections containing the item.</returns> + IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId); + + /// <summary> /// Gets the folder where collections are stored. /// </summary> /// <param name="createIfNeeded">Will create the collection folder on the storage if set to true.</param> diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index a71cdbd62c..052626355f 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; @@ -8,13 +6,16 @@ using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Dto { + /// <summary> + /// Options that control which fields and images are populated when building a <see cref="MediaBrowser.Model.Dto.BaseItemDto"/>. + /// </summary> public class DtoOptions { - private static readonly ItemFields[] DefaultExcludedFields = new[] - { + private static readonly ItemFields[] DefaultExcludedFields = + [ ItemFields.SeasonUserData, ItemFields.RefreshState - }; + ]; private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>(); @@ -22,11 +23,18 @@ namespace MediaBrowser.Controller.Dto .Except(DefaultExcludedFields) .ToArray(); + /// <summary> + /// Initializes a new instance of the <see cref="DtoOptions"/> class with all fields enabled. + /// </summary> public DtoOptions() : this(true) { } + /// <summary> + /// Initializes a new instance of the <see cref="DtoOptions"/> class. + /// </summary> + /// <param name="allFields">Whether to populate all available fields.</param> public DtoOptions(bool allFields) { ImageTypeLimit = int.MaxValue; @@ -38,23 +46,54 @@ namespace MediaBrowser.Controller.Dto ImageTypes = AllImageTypes; } + /// <summary> + /// Gets or sets the fields to populate on the DTO. + /// </summary> public IReadOnlyList<ItemFields> Fields { get; set; } + /// <summary> + /// Gets or sets the image types to populate on the DTO. + /// </summary> public IReadOnlyList<ImageType> ImageTypes { get; set; } + /// <summary> + /// Gets or sets the maximum number of images to return per image type. + /// </summary> public int ImageTypeLimit { get; set; } + /// <summary> + /// Gets or sets a value indicating whether image information is populated. + /// </summary> public bool EnableImages { get; set; } + /// <summary> + /// Gets or sets a value indicating whether program recording information is populated. + /// </summary> public bool AddProgramRecordingInfo { get; set; } + /// <summary> + /// Gets or sets a value indicating whether user data is populated. + /// </summary> public bool EnableUserData { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the currently airing program is populated. + /// </summary> public bool AddCurrentProgram { get; set; } + /// <summary> + /// Gets a value indicating whether the specified field is populated. + /// </summary> + /// <param name="field">The field to check.</param> + /// <returns><c>true</c> if the field is populated; otherwise, <c>false</c>.</returns> public bool ContainsField(ItemFields field) => Fields.Contains(field); + /// <summary> + /// Gets the number of images to return for the specified image type. + /// </summary> + /// <param name="type">The image type.</param> + /// <returns>The image limit for the type, or 0 if the type is not enabled.</returns> public int GetImageLimit(ImageType type) { if (EnableImages && ImageTypes.Contains(type)) diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index f1d507fcbd..f735abb09f 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -36,8 +36,14 @@ namespace MediaBrowser.Controller.Dto /// <param name="options">The options.</param> /// <param name="user">The user.</param> /// <param name="owner">The owner.</param> + /// <param name="skipVisibilityCheck">Skip redundant visibility check if items are already filtered.</param> /// <returns>The <see cref="IReadOnlyList{T}"/> of <see cref="BaseItemDto"/>.</returns> - IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null); + IReadOnlyList<BaseItemDto> GetBaseItemDtos( + IReadOnlyList<BaseItem> items, + DtoOptions options, + User? user = null, + BaseItem? owner = null, + bool skipVisibilityCheck = false); /// <summary> /// Gets the item by name dto. diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index 58841e5b78..c25694aba5 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -154,11 +154,6 @@ namespace MediaBrowser.Controller.Entities.Audio return "Artist-" + (Name ?? string.Empty).RemoveDiacritics(); } - protected override bool GetBlockUnratedValue(User user) - { - return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music); - } - public override UnratedItem GetBlockUnratedType() { return UnratedItem.Music; diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 7586b99e77..49a4ed4bf6 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -22,9 +22,7 @@ using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; @@ -89,12 +87,16 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; + private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private string _sortName; private string _forcedSortName; private string _name; + private string _originalLanguage; + public const char SlugChar = '-'; protected BaseItem() @@ -107,7 +109,6 @@ namespace MediaBrowser.Controller.Entities ImageInfos = Array.Empty<ItemImageInfo>(); ProductionLocations = Array.Empty<string>(); RemoteTrailers = Array.Empty<MediaUrl>(); - ExtraIds = Array.Empty<Guid>(); UserData = []; } @@ -218,6 +219,13 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public string OriginalTitle { get; set; } + [JsonIgnore] + public string OriginalLanguage + { + get => _originalLanguage; + set => _originalLanguage = LocalizationManager?.FindLanguageInfo(value)?.TwoLetterISOLanguageName ?? value; + } + /// <summary> /// Gets or sets the id. /// </summary> @@ -398,8 +406,6 @@ namespace MediaBrowser.Controller.Entities public int Height { get; set; } - public Guid[] ExtraIds { get; set; } - /// <summary> /// Gets the primary image path. /// </summary> @@ -492,6 +498,8 @@ namespace MediaBrowser.Controller.Entities public static IItemRepository ItemRepository { get; set; } + public static IItemCountService ItemCountService { get; set; } + public static IChapterManager ChapterManager { get; set; } public static IFileSystem FileSystem { get; set; } @@ -1093,8 +1101,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()) { @@ -1104,17 +1113,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() @@ -1122,20 +1129,12 @@ 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); var protocol = item.PathProtocol; - - // Resolve the item path so everywhere we use the media source it will always point to - // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link - // path will return null, so it's safe to check for all paths. var itemPath = item.Path; - if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo) - { - itemPath = linkInfo.FullName; - } var info = new MediaSourceInfo { @@ -1143,7 +1142,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, @@ -1172,11 +1171,18 @@ namespace MediaBrowser.Controller.Entities info.Video3DFormat = video.Video3DFormat; info.Timestamp = video.Timestamp; - if (video.IsShortcut) + if (video.IsShortcut && !string.IsNullOrEmpty(video.ShortcutPath)) { - info.IsRemote = true; - info.Path = video.ShortcutPath; - info.Protocol = MediaSourceManager.GetPathProtocol(info.Path); + var shortcutProtocol = MediaSourceManager.GetPathProtocol(video.ShortcutPath); + + // Only allow remote shortcut paths — local file paths in .strm files + // could be used to read arbitrary files from the server. + if (shortcutProtocol != MediaProtocol.File) + { + info.IsRemote = true; + info.Path = video.ShortcutPath; + info.Protocol = shortcutProtocol; + } } if (string.IsNullOrEmpty(info.Container)) @@ -1215,7 +1221,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>(); @@ -1223,12 +1229,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()); @@ -1285,6 +1310,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); @@ -1334,14 +1451,15 @@ namespace MediaBrowser.Controller.Entities return false; } - if (GetParents().Any(i => !i.IsVisible(user, true))) + var parents = GetParents().ToList(); + if (parents.Any(i => !i.IsVisible(user, true))) { return false; } if (checkFolders) { - var topParent = GetParents().LastOrDefault() ?? this; + var topParent = parents.Count > 0 ? parents[^1] : this; if (string.IsNullOrEmpty(topParent.Path)) { @@ -1352,8 +1470,27 @@ namespace MediaBrowser.Controller.Entities if (itemCollectionFolders.Count > 0) { - var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList(); - if (!itemCollectionFolders.Any(userCollectionFolders.Contains)) + var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders); + IEnumerable<Guid> userCollectionFolderIds; + if (blockedMediaFolders.Length > 0) + { + // User has blocked folders - get all library folders and exclude blocked ones + userCollectionFolderIds = LibraryManager.GetUserRootFolder().Children + .Select(i => i.Id) + .Where(id => !blockedMediaFolders.Contains(id)); + } + else if (user.HasPermission(PermissionKind.EnableAllFolders)) + { + // User can access all folders - no need to filter + return true; + } + else + { + // User has specific enabled folders + userCollectionFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders); + } + + if (!itemCollectionFolders.Any(userCollectionFolderIds.Contains)) { return false; } @@ -1395,7 +1532,13 @@ namespace MediaBrowser.Controller.Entities { var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); var newExtraIds = Array.ConvertAll(extras, x => x.Id); - var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds); + + var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery() + { + OwnerIds = [item.Id] + }).Select(e => e.Id).ToArray(); + + var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x)); if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { @@ -1409,16 +1552,15 @@ namespace MediaBrowser.Controller.Entities var subOptions = new MetadataRefreshOptions(options); if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) { - i.OwnerId = ownerId; - i.ParentId = Guid.Empty; subOptions.ForceSave = true; } + i.OwnerId = ownerId; + i.ParentId = Guid.Empty; return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); }); - // Cleanup removed extras - var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray(); + var removedExtraIds = currentExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray(); if (removedExtraIds.Length > 0) { var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery() @@ -1427,17 +1569,20 @@ namespace MediaBrowser.Controller.Entities }); foreach (var removedExtra in removedExtras) { - LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + // Only delete items that are actual extras (have ExtraType set) + // Items with OwnerId but no ExtraType might be alternate versions, not extras + if (removedExtra.ExtraType.HasValue) { - DeleteFileLocation = false - }); + LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + { + DeleteFileLocation = false + }); + } } } await Task.WhenAll(tasks).ConfigureAwait(false); - item.ExtraIds = newExtraIds; - return true; } @@ -1528,7 +1673,7 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Gets the preferred metadata language. + /// Gets the preferred metadata country code. /// </summary> /// <returns>System.String.</returns> public string GetPreferredMetadataCountryCode() @@ -1562,6 +1707,15 @@ namespace MediaBrowser.Controller.Entities return lang; } + /// <summary> + /// Gets the original language of the item, inheriting from parent items if necessary. + /// </summary> + /// <returns>System.String.</returns> + public virtual string GetInheritedOriginalLanguage() + { + return OriginalLanguage; + } + public virtual bool IsSaveLocalMetadataEnabled() { if (SourceType == SourceType.Channel) @@ -1601,11 +1755,10 @@ namespace MediaBrowser.Controller.Entities if (string.IsNullOrEmpty(rating)) { - Logger.LogDebug("{0} has no parental rating set.", Name); return !GetBlockUnratedValue(user); } - var ratingScore = LocalizationManager.GetRatingScore(rating); + var ratingScore = LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode()); // Could not determine rating level if (ratingScore is null) @@ -1647,7 +1800,7 @@ namespace MediaBrowser.Controller.Entities return null; } - return LocalizationManager.GetRatingScore(rating); + return LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode()); } public List<string> GetInheritedTags() @@ -1668,10 +1821,28 @@ namespace MediaBrowser.Controller.Entities return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } - private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck) + protected bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck) { - var allTags = GetInheritedTags(); - if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + var blockedTags = user.GetPreference(PreferenceKind.BlockedTags); + var allowedTags = user.GetPreference(PreferenceKind.AllowedTags); + + if (blockedTags.Length == 0 && allowedTags.Length == 0) + { + return true; + } + + // Normalize tags using the same logic as database queries + var normalizedBlockedTags = blockedTags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + var normalizedItemTags = GetInheritedTags() + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + // Check blocked tags - item is hidden if it has any blocked tag + if (normalizedBlockedTags.Overlaps(normalizedItemTags)) { return false; } @@ -1682,10 +1853,18 @@ namespace MediaBrowser.Controller.Entities return true; } - var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags); - if (!skipAllowedTagsCheck && allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + // Check allowed tags - item must have at least one allowed tag + if (!skipAllowedTagsCheck && allowedTags.Length > 0) { - return false; + var normalizedAllowedTags = allowedTags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + if (!normalizedAllowedTags.Overlaps(normalizedItemTags)) + { + return false; + } } return true; @@ -1798,10 +1977,23 @@ namespace MediaBrowser.Controller.Entities return item; } +#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy LinkedChild data private BaseItem FindLinkedChild(LinkedChild info) { - var path = info.Path; + // First try to find by ItemId (new preferred method) + if (info.ItemId.HasValue && !info.ItemId.Value.Equals(Guid.Empty)) + { + var item = LibraryManager.GetItemById(info.ItemId.Value); + if (item is not null) + { + return item; + } + Logger.LogWarning("Unable to find linked item by ItemId {0}", info.ItemId); + } + + // Fall back to Path (legacy method) + var path = info.Path; if (!string.IsNullOrEmpty(path)) { path = FileSystem.MakeAbsolutePath(ContainingFolderPath, path); @@ -1816,13 +2008,14 @@ namespace MediaBrowser.Controller.Entities return itemByPath; } + // Fall back to LibraryItemId (legacy method) if (!string.IsNullOrEmpty(info.LibraryItemId)) { var item = LibraryManager.GetItemById(info.LibraryItemId); if (item is null) { - Logger.LogWarning("Unable to find linked item at path {0}", info.Path); + Logger.LogWarning("Unable to find linked item by LibraryItemId {0}", info.LibraryItemId); } return item; @@ -1830,6 +2023,7 @@ namespace MediaBrowser.Controller.Entities return null; } +#pragma warning restore CS0618 /// <summary> /// Adds a studio to the item. @@ -1929,12 +2123,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> @@ -2129,17 +2334,6 @@ namespace MediaBrowser.Controller.Entities }; } - // Music albums usually don't have dedicated backdrops, so return one from the artist instead - if (GetType() == typeof(MusicAlbum) && imageType == ImageType.Backdrop) - { - var artist = FindParent<MusicArtist>(); - - if (artist is not null) - { - return artist.GetImages(imageType).ElementAtOrDefault(imageIndex); - } - } - return GetImages(imageType) .ElementAtOrDefault(imageIndex); } @@ -2421,7 +2615,13 @@ namespace MediaBrowser.Controller.Entities return path; } - public virtual void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields) + public virtual void FillUserDataDtoValues( + UserItemDataDto dto, + UserItemData userData, + BaseItemDto itemDto, + User user, + DtoOptions fields, + (int Played, int Total)? precomputedCounts = null) { if (RunTimeTicks.HasValue) { @@ -2621,7 +2821,7 @@ namespace MediaBrowser.Controller.Entities .Select(i => i.OfficialRating) .Where(i => !string.IsNullOrEmpty(i)) .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(rating => (rating, LocalizationManager.GetRatingScore(rating))) + .Select(rating => (rating, LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode()))) .OrderBy(i => i.Item2 is null ? 1001 : i.Item2.Score) .ThenBy(i => i.Item2 is null ? 1001 : i.Item2.SubScore) .Select(i => i.rating); @@ -2641,7 +2841,7 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy) { - return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray(); + return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray(); } public IReadOnlyList<BaseItem> GetThemeVideos(User user = null) @@ -2651,33 +2851,46 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy) { - return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray(); + return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray(); + } + + /// <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> /// 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> /// <returns>An enumerable containing the items.</returns> - public IEnumerable<BaseItem> GetExtras() + public IEnumerable<BaseItem> GetExtras(User user = null) { - return ExtraIds - .Select(LibraryManager.GetItemById) - .Where(i => i is not null) - .OrderBy(i => i.SortName); + return LibraryManager.GetItemList(new InternalItemsQuery(user) + { + OwnerIds = GetExtraOwnerIds(), + OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] + }); } /// <summary> /// Get all extras with specific types that are associated with this item. /// </summary> /// <param name="extraTypes">The types of extras to retrieve.</param> + /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> /// <returns>An enumerable containing the extras.</returns> - public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes) + public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes, User user = null) { - return ExtraIds - .Select(LibraryManager.GetItemById) - .Where(i => i is not null) - .Where(i => i.ExtraType.HasValue && extraTypes.Contains(i.ExtraType.Value)) - .OrderBy(i => i.SortName); + return LibraryManager.GetItemList(new InternalItemsQuery(user) + { + OwnerIds = GetExtraOwnerIds(), + ExtraTypes = extraTypes.ToArray(), + OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] + }); } public virtual long GetRunTimeTicksForPlayState() diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index ca79e62454..ffdc8421da 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -45,6 +45,11 @@ namespace MediaBrowser.Controller.Entities } /// <summary> + /// Event raised when library options are updated for any collection folder. + /// </summary> + public static event EventHandler<LibraryOptionsUpdatedEventArgs> LibraryOptionsUpdated; + + /// <summary> /// Gets the display preferences id. /// </summary> /// <remarks> @@ -74,14 +79,27 @@ namespace MediaBrowser.Controller.Entities public CollectionType? CollectionType { get; set; } /// <summary> - /// Gets the item's children. + /// Gets or sets the item's children. /// </summary> /// <remarks> /// Our children are actually just references to the ones in the physical root... + /// Setting to null propagates invalidation to physical folders since the getter + /// always delegates to <see cref="GetActualChildren"/> and never reads the backing field. /// </remarks> /// <value>The actual children.</value> [JsonIgnore] - public override IEnumerable<BaseItem> Children => GetActualChildren(); + public override IEnumerable<BaseItem> Children + { + get => GetActualChildren(); + set + { + // The getter delegates to physical folders, so invalidate their caches. + foreach (var folder in GetPhysicalFolders(true)) + { + folder.Children = null; + } + } + } [JsonIgnore] public override bool SupportsPeople => false; @@ -168,6 +186,8 @@ namespace MediaBrowser.Controller.Entities } XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path)); + + LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options)); } public static void OnCollectionFolderChange() diff --git a/MediaBrowser.Controller/Entities/Extensions.cs b/MediaBrowser.Controller/Entities/Extensions.cs index c56603a3eb..380041af84 100644 --- a/MediaBrowser.Controller/Entities/Extensions.cs +++ b/MediaBrowser.Controller/Entities/Extensions.cs @@ -34,7 +34,7 @@ namespace MediaBrowser.Controller.Entities } else { - item.RemoteTrailers = [..item.RemoteTrailers, mediaUrl]; + item.RemoteTrailers = [.. item.RemoteTrailers, mediaUrl]; } } } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 2ecb6cbdff..b1f7f29bad 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -59,6 +59,10 @@ namespace MediaBrowser.Controller.Entities /// <value><c>true</c> if this instance is root; otherwise, <c>false</c>.</value> public bool IsRoot { get; set; } + /// <summary> + /// Gets or sets the linked children. + /// </summary> + [JsonIgnore] public LinkedChild[] LinkedChildren { get; set; } [JsonIgnore] @@ -380,6 +384,7 @@ namespace MediaBrowser.Controller.Entities cancellationToken.ThrowIfCancellationRequested(); var validChildren = new List<BaseItem>(); + var accessibleChildren = new List<BaseItem>(); var validChildrenNeedGeneration = false; if (IsFileProtocol) @@ -416,6 +421,17 @@ namespace MediaBrowser.Controller.Entities // Create a list for our validated children var newItems = new List<BaseItem>(); + var actuallyRemoved = new List<BaseItem>(); + + // Build a reverse path→item lookup for detecting type changes + var currentChildrenByPath = new Dictionary<string, BaseItem>(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in currentChildren) + { + if (!string.IsNullOrEmpty(kvp.Value.Path)) + { + currentChildrenByPath.TryAdd(kvp.Value.Path, kvp.Value); + } + } cancellationToken.ThrowIfCancellationRequested(); @@ -423,12 +439,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) { @@ -443,18 +466,47 @@ namespace MediaBrowser.Controller.Entities continue; } + // Check if an existing item occupies the same path with different type/ID + if (!string.IsNullOrEmpty(child.Path) + && currentChildrenByPath.TryGetValue(child.Path, out var staleItem) + && !staleItem.Id.Equals(child.Id)) + { + Logger.LogInformation( + "Item type changed at {Path}: {OldType} -> {NewType}, removing stale entry", + child.Path, + staleItem.GetType().Name, + child.GetType().Name); + + currentChildren.Remove(staleItem.Id); + currentChildrenByPath.Remove(child.Path); + staleItem.SetParent(null); + LibraryManager.DeleteItem(staleItem, new DeleteOptions { DeleteFileLocation = false }, this, false); + actuallyRemoved.Add(staleItem); + } + // Brand new item - needs to be added 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; - var actuallyRemoved = new List<BaseItem>(); + // If it's an AggregateFolder, don't remove - if (shouldRemove && itemsRemoved.Count > 0) + // Collect replaced primaries for deferred deletion (after CreateItems) + var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>(); + + // Build a set of paths that are alternate versions of valid children + // These items should not be deleted - they're managed by their primary video + var alternateVersionPaths = validChildren + .OfType<Video>() + .SelectMany(v => v.LocalAlternateVersions ?? []) + .Where(p => !string.IsNullOrEmpty(p)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (itemsRemoved.Count > 0) { foreach (var item in itemsRemoved) { @@ -464,6 +516,40 @@ namespace MediaBrowser.Controller.Entities continue; } + // Skip items that are alternate versions of another video + if (item is Video video) + { + // Check if path is in LocalAlternateVersions of any valid child + if (!string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path)) + { + Logger.LogDebug("Item path matches an alternate version, skipping deletion: {Path}", item.Path); + continue; + } + } + + // Defer deletion if this primary video is being replaced by a new primary + // that takes over its alternates. Deleting now would trigger premature + // promotion inside DeleteItem and write stale paths to collection NFOs. + if (item is Video primaryVideo + && !primaryVideo.PrimaryVersionId.HasValue + && primaryVideo.OwnerId.IsEmpty() + && (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p))) + { + var newPrimary = newItems + .OfType<Video>() + .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) + .Any(p => (primaryVideo.LocalAlternateVersions ?? []) + .Any(op => string.Equals(op, p, StringComparison.OrdinalIgnoreCase)))); + if (newPrimary is not null) + { + Logger.LogDebug("Deferring deletion of replaced primary: {Path}", item.Path); + replacedPrimaries.Add((primaryVideo, newPrimary)); + actuallyRemoved.Add(item); + item.SetParent(null); + continue; + } + } + if (item.IsFileProtocol) { Logger.LogDebug("Removed item: {Path}", item.Path); @@ -480,6 +566,106 @@ namespace MediaBrowser.Controller.Entities LibraryManager.CreateItems(newItems, this, cancellationToken); } + // Process deferred replaced-primary deletions now that new primaries exist in DB/cache. + // This avoids the premature promotion that would occur if DeleteItem ran before CreateItems. + foreach (var (oldPrimary, newPrimary) in replacedPrimaries) + { + Logger.LogInformation( + "Processing deferred deletion of replaced primary {OldName} ({OldId}), new primary {NewName} ({NewId})", + oldPrimary.Name, + oldPrimary.Id, + newPrimary.Name, + newPrimary.Id); + + // Reroute collection/playlist references from old primary to new primary + await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false); + + // Transfer alternates from old primary to new primary + var localAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet(); + var allAlternateIds = localAlternateIds + .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id)) + .Distinct() + .ToList(); + + foreach (var altId in allAlternateIds) + { + if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id)) + { + altVideo.SetPrimaryVersionId(newPrimary.Id); + altVideo.OwnerId = localAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty; + await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + + // Clear alternate arrays so DeleteItem won't trigger promotion + oldPrimary.LocalAlternateVersions = []; + oldPrimary.LinkedAlternateVersions = []; + + // Safe to delete now — no promotion will happen + LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false); + } + + // Demote old primaries that are now alternate versions of newly created primaries. + // 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). + // Items in replacedPrimaries are excluded (already in actuallyRemoved). + var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>(); + foreach (var item in itemsRemoved.Except(actuallyRemoved)) + { + if (item is Video video + && video.OwnerId.IsEmpty() + && !string.IsNullOrEmpty(item.Path) + && alternateVersionPaths.Contains(item.Path)) + { + var newPrimary = newItems + .OfType<Video>() + .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) + .Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase))); + if (newPrimary is not null) + { + oldPrimariesToDemote.Add((video, newPrimary)); + } + } + } + + foreach (var (oldPrimary, newPrimary) in oldPrimariesToDemote) + { + Logger.LogInformation( + "Demoting old primary {OldName} ({OldId}) to alternate of new primary {NewName} ({NewId})", + oldPrimary.Name, + oldPrimary.Id, + newPrimary.Name, + newPrimary.Id); + + // 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) + .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id)) + .Distinct() + .ToList(); + + foreach (var altId in oldAlternateIds) + { + if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id)) + { + altVideo.SetPrimaryVersionId(newPrimary.Id); + altVideo.OwnerId = newPrimary.Id; + await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + + // Then: demote old primary — clear its arrays and set it as alternate of new primary + oldPrimary.LocalAlternateVersions = []; + oldPrimary.LinkedAlternateVersions = []; + oldPrimary.SetPrimaryVersionId(newPrimary.Id); + oldPrimary.OwnerId = newPrimary.Id; + await oldPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + + // Re-route playlist/collection references from old primary to new primary + await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false); + } + // After removing items, reattach any detached user data to remaining children // that share the same user data keys (eg. same episode replaced with a new file). if (actuallyRemoved.Count > 0) @@ -526,7 +712,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) @@ -565,7 +751,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); } } } @@ -716,36 +902,10 @@ namespace MediaBrowser.Controller.Entities public QueryResult<BaseItem> QueryRecursive(InternalItemsQuery query) { - var user = query.User; - - if (!query.ForceDirect && RequiresPostFiltering(query)) + if (!query.ForceDirect && CollapseBoxSetItems(query, this, query.User, ConfigurationManager)) { - IEnumerable<BaseItem> items; - Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - - var totalCount = 0; - if (query.User is null) - { - items = GetRecursiveChildren(filter); - totalCount = items.Count(); - } - else - { - // Save pagination params before clearing them to prevent pagination from happening - // before sorting. PostFilterAndSort will apply pagination after sorting. - var limit = query.Limit; - var startIndex = query.StartIndex; - query.Limit = null; - query.StartIndex = null; - - items = GetRecursiveChildren(user, query, out totalCount); - - // Restore pagination params so PostFilterAndSort can apply them after sorting - query.Limit = limit; - query.StartIndex = startIndex; - } - - return PostFilterAndSort(items, query); + query.CollapseBoxSetItems = true; + SetCollapseBoxSetItemTypes(query); } if (this is not UserRootFolder @@ -755,15 +915,18 @@ namespace MediaBrowser.Controller.Entities query.Parent = this; } - if (RequiresPostFiltering2(query)) + // BoxSets and Playlists can have per-user visibility (shares/open access) that is stored in the + // serialized item data and cannot be evaluated by the database query, so filter them in memory. + if (query.IncludeItemTypes.Length > 0 + && query.IncludeItemTypes.All(t => t == BaseItemKind.BoxSet || t == BaseItemKind.Playlist)) { - return QueryWithPostFiltering2(query); + return QueryWithPostFiltering(query); } return LibraryManager.GetItemsResult(query); } - protected QueryResult<BaseItem> QueryWithPostFiltering2(InternalItemsQuery query) + protected QueryResult<BaseItem> QueryWithPostFiltering(InternalItemsQuery query) { var startIndex = query.StartIndex; var limit = query.Limit; @@ -776,7 +939,7 @@ namespace MediaBrowser.Controller.Entities if (user is not null) { - // needed for boxsets + // needed for boxsets and playlists itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User)); } @@ -809,120 +972,6 @@ namespace MediaBrowser.Controller.Entities returnItems.ToArray()); } - private bool RequiresPostFiltering2(InternalItemsQuery query) - { - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet) - { - Logger.LogDebug("Query requires post-filtering due to BoxSet query"); - return true; - } - - return false; - } - - private bool RequiresPostFiltering(InternalItemsQuery query) - { - if (LinkedChildren.Length > 0) - { - if (this is not ICollectionFolder) - { - Logger.LogDebug("{Type}: Query requires post-filtering due to LinkedChildren.", GetType().Name); - return true; - } - } - - // Filter by Video3DFormat - if (query.Is3D.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to Is3D"); - return true; - } - - if (query.HasOfficialRating.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasOfficialRating"); - return true; - } - - if (query.IsPlaceHolder.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to IsPlaceHolder"); - return true; - } - - if (query.HasSpecialFeature.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasSpecialFeature"); - return true; - } - - if (query.HasSubtitles.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasSubtitles"); - return true; - } - - if (query.HasTrailer.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasTrailer"); - return true; - } - - if (query.HasThemeSong.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasThemeSong"); - return true; - } - - if (query.HasThemeVideo.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasThemeVideo"); - return true; - } - - // Filter by VideoType - if (query.VideoTypes.Length > 0) - { - Logger.LogDebug("Query requires post-filtering due to VideoTypes"); - return true; - } - - if (CollapseBoxSetItems(query, this, query.User, ConfigurationManager)) - { - Logger.LogDebug("Query requires post-filtering due to CollapseBoxSetItems"); - return true; - } - - if (!query.AdjacentTo.IsNullOrEmpty()) - { - Logger.LogDebug("Query requires post-filtering due to AdjacentTo"); - return true; - } - - if (query.SeriesStatuses.Length > 0) - { - Logger.LogDebug("Query requires post-filtering due to SeriesStatuses"); - return true; - } - - if (query.AiredDuringSeason.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to AiredDuringSeason"); - return true; - } - - if (query.IsPlayed.HasValue) - { - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(BaseItemKind.Series)) - { - Logger.LogDebug("Query requires post-filtering due to IsPlayed"); - return true; - } - } - - return false; - } - private static BaseItem[] SortItemsByRequest(InternalItemsQuery query, IReadOnlyList<BaseItem> items) { return items.OrderBy(i => Array.IndexOf(query.ItemIds, i.Id)).ToArray(); @@ -990,14 +1039,12 @@ namespace MediaBrowser.Controller.Entities var user = query.User; - Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - IEnumerable<BaseItem> items; int totalItemCount = 0; if (query.User is null) { - items = Children.Where(filter); + items = UserViewBuilder.Filter(Children, user, query, UserDataManager, LibraryManager); totalItemCount = items.Count(); } else @@ -1012,7 +1059,12 @@ namespace MediaBrowser.Controller.Entities NameLessThan = query.NameLessThan }; - items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter); + items = UserViewBuilder.Filter( + GetChildren(user, true, out totalItemCount, childQuery), + user, + query, + UserDataManager, + LibraryManager); } return PostFilterAndSort(items, query); @@ -1026,40 +1078,42 @@ namespace MediaBrowser.Controller.Entities if (user is not null) { items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager); - } -#pragma warning disable CA1309 - if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater)) - { - items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1); + // After collapse, BoxSets may have replaced items whose names matched the filter + // but the BoxSet's own name may not match. Re-apply name filtering so BoxSets + // appear under the correct letter (e.g. "Jump Street" under J, not under #). + items = ApplyNameFilter(items, query); } - if (!string.IsNullOrEmpty(query.NameStartsWith)) + var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); + var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); + + if (query.EnableTotalRecordCount) { - items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)); + result.TotalRecordCount = filteredItems.Count; } - if (!string.IsNullOrEmpty(query.NameLessThan)) + return result; + } + + private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query) + { + if (!string.IsNullOrWhiteSpace(query.NameStartsWith)) { - items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1); + items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase)); } -#pragma warning restore CA1309 - // This must be the last filter - if (!query.AdjacentTo.IsNullOrEmpty()) + if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater)) { - items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); + items = items.Where(i => string.Compare(i.SortName, query.NameStartsWithOrGreater, StringComparison.OrdinalIgnoreCase) >= 0); } - var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); - var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); - - if (query.EnableTotalRecordCount) + if (!string.IsNullOrWhiteSpace(query.NameLessThan)) { - result.TotalRecordCount = filteredItems.Count; + items = items.Where(i => string.Compare(i.SortName, query.NameLessThan, StringComparison.OrdinalIgnoreCase) < 0); } - return result; + return items; } private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded( @@ -1167,6 +1221,33 @@ namespace MediaBrowser.Controller.Entities return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query); } + private void SetCollapseBoxSetItemTypes(InternalItemsQuery query) + { + var config = ConfigurationManager.Configuration; + bool collapseMovies = config.EnableGroupingMoviesIntoCollections; + bool collapseSeries = config.EnableGroupingShowsIntoCollections; + + if (collapseMovies && collapseSeries) + { + // Empty means collapse all types + query.CollapseBoxSetItemTypes = []; + return; + } + + var types = new List<BaseItemKind>(); + if (collapseMovies) + { + types.Add(BaseItemKind.Movie); + } + + if (collapseSeries) + { + types.Add(BaseItemKind.Series); + } + + query.CollapseBoxSetItemTypes = types.ToArray(); + } + private static bool AllowBoxSetCollapsing(InternalItemsQuery request) { if (request.IsFavorite.HasValue) @@ -1418,8 +1499,7 @@ namespace MediaBrowser.Controller.Entities .Where(e => e.IsVisible(user)) .ToArray(); - var realChildren = visibleChildren - .Where(e => query is null || UserViewBuilder.FilterItem(e, query)) + var realChildren = UserViewBuilder.Filter(visibleChildren, query.User, query, UserDataManager, LibraryManager) .ToArray(); var childCount = realChildren.Length; @@ -1525,17 +1605,11 @@ namespace MediaBrowser.Controller.Entities /// <returns>IEnumerable{BaseItem}.</returns> public List<BaseItem> GetLinkedChildren() { - var linkedChildren = LinkedChildren; - var list = new List<BaseItem>(linkedChildren.Length); - - foreach (var i in linkedChildren) + var resolved = ResolveLinkedChildren(LinkedChildren); + var list = new List<BaseItem>(resolved.Count); + foreach (var (_, item) in resolved) { - var child = GetLinkedChild(i); - - if (child is not null) - { - list.Add(child); - } + list.Add(item); } return list; @@ -1636,12 +1710,74 @@ namespace MediaBrowser.Controller.Entities /// <returns>IEnumerable{BaseItem}.</returns> public IReadOnlyList<Tuple<LinkedChild, BaseItem>> GetLinkedChildrenInfos() { - return LinkedChildren - .Select(i => new Tuple<LinkedChild, BaseItem>(i, GetLinkedChild(i))) - .Where(i => i.Item2 is not null) + return ResolveLinkedChildren(LinkedChildren) + .Select(t => new Tuple<LinkedChild, BaseItem>(t.Info, t.Item)) .ToArray(); } + /// <summary> + /// Resolves a list of <see cref="LinkedChild"/> entries to their <see cref="BaseItem"/> targets, + /// batching the database lookup across all entries with a known ItemId. + /// Entries without a usable ItemId fall back to the per-entry <see cref="BaseItem.GetLinkedChild"/> + /// path (legacy path-based resolution). + /// </summary> + /// <param name="linkedChildren">Linked children to resolve.</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) + { + var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count); + if (linkedChildren.Count == 0) + { + return resolved; + } + + var idsToBatch = new HashSet<Guid>(); + foreach (var info in linkedChildren) + { + if (info.ItemId.HasValue && !info.ItemId.Value.IsEmpty()) + { + idsToBatch.Add(info.ItemId.Value); + } + } + + Dictionary<Guid, BaseItem> byId = null; + if (idsToBatch.Count > 0) + { + var batched = LibraryManager.GetItemList(new InternalItemsQuery + { + ItemIds = [.. idsToBatch] + }); + byId = new Dictionary<Guid, BaseItem>(batched.Count); + foreach (var item in batched) + { + byId[item.Id] = item; + } + } + + foreach (var info in linkedChildren) + { + BaseItem item = null; + if (byId is not null && info.ItemId.HasValue && byId.TryGetValue(info.ItemId.Value, out var batchedItem)) + { + item = batchedItem; + } + else + { + // ItemId is missing/empty or the batched query couldn't return the item + // (e.g. it has been removed). Fall back to per-entry resolution, which also + // handles legacy path-based linked children. + item = GetLinkedChild(info); + } + + if (item is not null) + { + resolved.Add((info, item)); + } + } + + return resolved; + } + protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { var changesFound = false; @@ -1680,11 +1816,13 @@ namespace MediaBrowser.Controller.Entities if (!string.IsNullOrEmpty(resolvedPath)) { +#pragma warning disable CS0618 // Type or member is obsolete - shortcuts require Path for lazy ItemId resolution return new LinkedChild { Path = resolvedPath, Type = LinkedChildType.Shortcut }; +#pragma warning restore CS0618 } Logger.LogError("Error resolving shortcut {0}", i.FullName); @@ -1712,12 +1850,6 @@ namespace MediaBrowser.Controller.Entities } } - foreach (var child in LinkedChildren) - { - // Reset the cached value - child.ItemId = null; - } - return false; } @@ -1795,45 +1927,63 @@ namespace MediaBrowser.Controller.Entities return !IsPlayed(user, userItemData); } - public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields) + public override void FillUserDataDtoValues( + UserItemDataDto dto, + UserItemData userData, + BaseItemDto itemDto, + User user, + DtoOptions fields, + (int Played, int Total)? precomputedCounts = null) { if (!SupportsUserDataFromChildren) { return; } - if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)) + if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))) { - itemDto.RecursiveItemCount = GetRecursiveChildCount(user); - } + int playedCount; + int totalCount; - if (SupportsPlayedStatus) - { - var unplayedQueryResult = GetItems(new InternalItemsQuery(user) + if (precomputedCounts.HasValue) + { + // Use batch-fetched counts (avoids N+1 queries) + (playedCount, totalCount) = precomputedCounts.Value; + } + else { - Recursive = true, - IsFolder = false, - IsVirtualItem = false, - EnableTotalRecordCount = true, - Limit = 0, - IsPlayed = false, - DtoOptions = new DtoOptions(false) + // Fall back to per-item query when no batch data is available + var query = new InternalItemsQuery(user); + + if (LinkedChildren.Length > 0) { - EnableImages = false + (playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCountFromLinkedChildren(query, Id); } - }).TotalRecordCount; - - dto.UnplayedItemCount = unplayedQueryResult; + else + { + (playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCount(query, Id); + } + } - if (itemDto?.RecursiveItemCount > 0) + if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)) { - var unplayedPercentage = ((double)unplayedQueryResult / itemDto.RecursiveItemCount.Value) * 100; - dto.PlayedPercentage = 100 - unplayedPercentage; - dto.Played = dto.PlayedPercentage.Value >= 100; + itemDto.RecursiveItemCount = totalCount; } - else + + if (SupportsPlayedStatus) { - dto.Played = (dto.UnplayedItemCount ?? 0) == 0; + var unplayedCount = totalCount - playedCount; + dto.UnplayedItemCount = unplayedCount; + + if (totalCount > 0) + { + dto.PlayedPercentage = playedCount / (double)totalCount * 100; + dto.Played = playedCount >= totalCount; + } + else + { + dto.Played = true; + } } } } 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 076a592922..3b1f6a961f 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -10,6 +10,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Entities { @@ -17,43 +18,49 @@ namespace MediaBrowser.Controller.Entities { public InternalItemsQuery() { - AlbumArtistIds = Array.Empty<Guid>(); - AlbumIds = Array.Empty<Guid>(); - AncestorIds = Array.Empty<Guid>(); - ArtistIds = Array.Empty<Guid>(); - BlockUnratedItems = Array.Empty<UnratedItem>(); - BoxSetLibraryFolders = Array.Empty<Guid>(); - ChannelIds = Array.Empty<Guid>(); - ContributingArtistIds = Array.Empty<Guid>(); + AlbumArtistIds = []; + AlbumIds = []; + AncestorIds = []; + LinkedChildAncestorIds = []; + ArtistIds = []; + BlockUnratedItems = []; + BoxSetLibraryFolders = []; + ChannelIds = []; + ContributingArtistIds = []; DtoOptions = new DtoOptions(); EnableTotalRecordCount = true; - ExcludeArtistIds = Array.Empty<Guid>(); - ExcludeInheritedTags = Array.Empty<string>(); - IncludeInheritedTags = Array.Empty<string>(); - ExcludeItemIds = Array.Empty<Guid>(); - ExcludeItemTypes = Array.Empty<BaseItemKind>(); - ExcludeTags = Array.Empty<string>(); - GenreIds = Array.Empty<Guid>(); - Genres = Array.Empty<string>(); + ExcludeArtistIds = []; + ExcludeInheritedTags = []; + IncludeInheritedTags = []; + ExcludeItemIds = []; + ExcludeItemTypes = []; + ExcludeTags = []; + GenreIds = []; + Genres = []; GroupByPresentationUniqueKey = true; - ImageTypes = Array.Empty<ImageType>(); - IncludeItemTypes = Array.Empty<BaseItemKind>(); - ItemIds = Array.Empty<Guid>(); - MediaTypes = Array.Empty<MediaType>(); - OfficialRatings = Array.Empty<string>(); - OrderBy = Array.Empty<(ItemSortBy, SortOrder)>(); - PersonIds = Array.Empty<Guid>(); - PersonTypes = Array.Empty<string>(); - PresetViews = Array.Empty<CollectionType?>(); - SeriesStatuses = Array.Empty<SeriesStatus>(); - SourceTypes = Array.Empty<SourceType>(); - StudioIds = Array.Empty<Guid>(); - Tags = Array.Empty<string>(); - TopParentIds = Array.Empty<Guid>(); - TrailerTypes = Array.Empty<TrailerType>(); - VideoTypes = Array.Empty<VideoType>(); - Years = Array.Empty<int>(); + ImageTypes = []; + IncludeItemTypes = []; + ItemIds = []; + OwnerIds = []; + ExtraTypes = []; + MediaTypes = []; + OfficialRatings = []; + OrderBy = []; + OwnerIds = []; + PersonIds = []; + PersonTypes = []; + PresetViews = []; + SeriesStatuses = []; + SourceTypes = []; + StudioIds = []; + Tags = []; + TopParentIds = []; + TrailerTypes = []; + VideoTypes = []; + Years = []; SkipDeserialization = false; + AudioLanguages = []; + SubtitleLanguages = []; } public InternalItemsQuery(User? user) @@ -65,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; } @@ -109,6 +212,12 @@ namespace MediaBrowser.Controller.Entities public bool? CollapseBoxSetItems { get; set; } + /// <summary> + /// Gets or sets the item types that should be collapsed into box sets. + /// When empty, all types are collapsed. When set, only items of these types are replaced by their parent box set. + /// </summary> + public BaseItemKind[] CollapseBoxSetItemTypes { get; set; } = []; + public string? NameStartsWithOrGreater { get; set; } public string? NameStartsWith { get; set; } @@ -133,6 +242,10 @@ namespace MediaBrowser.Controller.Entities public Guid[] ItemIds { get; set; } + public Guid[] OwnerIds { get; set; } + + public ExtraType[] ExtraTypes { get; set; } + public Guid[] ExcludeItemIds { get; set; } public Guid? AdjacentTo { get; set; } @@ -249,6 +362,12 @@ namespace MediaBrowser.Controller.Entities public Guid[] AncestorIds { get; set; } + /// <summary> + /// Gets or sets a list of ancestor ids that the item's linked children must descend from. + /// Useful for filtering BoxSets/Playlists to only those that contain items from a specific library. + /// </summary> + public Guid[] LinkedChildAncestorIds { get; set; } + public Guid[] TopParentIds { get; set; } public CollectionType?[] PresetViews { get; set; } @@ -337,6 +456,8 @@ namespace MediaBrowser.Controller.Entities public Dictionary<string, string>? HasAnyProviderId { get; set; } + public Dictionary<string, string[]>? HasAnyProviderIds { get; set; } + public Guid[] AlbumArtistIds { get; set; } public Guid[] BoxSetLibraryFolders { get; set; } @@ -347,6 +468,12 @@ namespace MediaBrowser.Controller.Entities public bool? HasOwnerId { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to include items with an OwnerId + /// (additional parts, alternate versions) that are normally excluded from general queries. + /// </summary> + public bool IncludeOwnedItems { get; set; } + public bool? Is4K { get; set; } public int? MaxHeight { get; set; } @@ -363,6 +490,12 @@ namespace MediaBrowser.Controller.Entities public bool SkipDeserialization { get; set; } + public bool IncludeExtras { get; set; } + + public IReadOnlyList<string> AudioLanguages { get; set; } + + public IReadOnlyList<string> SubtitleLanguages { get; set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; @@ -388,5 +521,75 @@ namespace MediaBrowser.Controller.Entities User = user; } + + public void ApplyFilters(ItemFilter[] filters) + { + static void ThrowConflictingFilters() + => throw new ArgumentException("Conflicting filters", nameof(filters)); + + foreach (var filter in filters) + { + switch (filter) + { + case ItemFilter.IsFolder: + if (filters.Contains(ItemFilter.IsNotFolder)) + { + ThrowConflictingFilters(); + } + + IsFolder = true; + break; + case ItemFilter.IsNotFolder: + if (filters.Contains(ItemFilter.IsFolder)) + { + ThrowConflictingFilters(); + } + + IsFolder = false; + break; + case ItemFilter.IsUnplayed: + if (filters.Contains(ItemFilter.IsPlayed)) + { + ThrowConflictingFilters(); + } + + IsPlayed = false; + break; + case ItemFilter.IsPlayed: + if (filters.Contains(ItemFilter.IsUnplayed)) + { + ThrowConflictingFilters(); + } + + IsPlayed = true; + break; + case ItemFilter.IsFavorite: + IsFavorite = true; + break; + case ItemFilter.IsResumable: + IsResumable = true; + break; + case ItemFilter.Likes: + if (filters.Contains(ItemFilter.Dislikes)) + { + ThrowConflictingFilters(); + } + + IsLiked = true; + break; + case ItemFilter.Dislikes: + if (filters.Contains(ItemFilter.Likes)) + { + ThrowConflictingFilters(); + } + + IsLiked = false; + break; + case ItemFilter.IsFavoriteOrLikes: + IsFavoriteOrLiked = true; + break; + } + } + } } } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 203a16a668..e12ba22343 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -21,6 +21,8 @@ namespace MediaBrowser.Controller.Entities ExcludePersonTypes = excludePersonTypes; } + public int? StartIndex { get; set; } + /// <summary> /// Gets or sets the maximum number of items the query should return. /// </summary> @@ -28,6 +30,8 @@ namespace MediaBrowser.Controller.Entities public Guid ItemId { get; set; } + public Guid? ParentId { get; set; } + public IReadOnlyList<string> PersonTypes { get; } public IReadOnlyList<string> ExcludePersonTypes { get; } @@ -38,6 +42,12 @@ namespace MediaBrowser.Controller.Entities public string NameContains { get; set; } + public string NameStartsWith { get; set; } + + public string NameLessThan { get; set; } + + public string NameStartsWithOrGreater { get; set; } + public User User { get; set; } public bool? IsFavorite { get; set; } diff --git a/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs b/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs new file mode 100644 index 0000000000..7590ad7d36 --- /dev/null +++ b/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs @@ -0,0 +1,31 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Entities; + +/// <summary> +/// Event arguments for when library options are updated. +/// </summary> +public class LibraryOptionsUpdatedEventArgs : EventArgs +{ + /// <summary> + /// Initializes a new instance of the <see cref="LibraryOptionsUpdatedEventArgs"/> class. + /// </summary> + /// <param name="libraryPath">The path of the library whose options were updated.</param> + /// <param name="libraryOptions">The updated library options.</param> + public LibraryOptionsUpdatedEventArgs(string libraryPath, LibraryOptions libraryOptions) + { + LibraryPath = libraryPath; + LibraryOptions = libraryOptions; + } + + /// <summary> + /// Gets the path of the library whose options were updated. + /// </summary> + public string LibraryPath { get; } + + /// <summary> + /// Gets the updated library options. + /// </summary> + public LibraryOptions LibraryOptions { get; } +} diff --git a/MediaBrowser.Controller/Entities/LinkedChild.cs b/MediaBrowser.Controller/Entities/LinkedChild.cs index 98e4f525f5..a3aa9dd0c9 100644 --- a/MediaBrowser.Controller/Entities/LinkedChild.cs +++ b/MediaBrowser.Controller/Entities/LinkedChild.cs @@ -3,7 +3,6 @@ #pragma warning disable CS1591 using System; -using System.Globalization; namespace MediaBrowser.Controller.Entities { @@ -13,10 +12,18 @@ namespace MediaBrowser.Controller.Entities { } + /// <summary> + /// Gets or sets the path. + /// </summary> + [Obsolete("Use ItemId instead")] public string Path { get; set; } public LinkedChildType Type { get; set; } + /// <summary> + /// Gets or sets the library item id. + /// </summary> + [Obsolete("Use ItemId instead")] public string LibraryItemId { get; set; } /// <summary> @@ -28,18 +35,11 @@ namespace MediaBrowser.Controller.Entities { ArgumentNullException.ThrowIfNull(item); - var child = new LinkedChild + return new LinkedChild { - Path = item.Path, + ItemId = item.Id, Type = LinkedChildType.Manual }; - - if (string.IsNullOrEmpty(child.Path)) - { - child.LibraryItemId = item.Id.ToString("N", CultureInfo.InvariantCulture); - } - - return child; } } } diff --git a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs index 4f13ac61fe..8b611345f4 100644 --- a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs +++ b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs @@ -19,17 +19,34 @@ namespace MediaBrowser.Controller.Entities public bool Equals(LinkedChild x, LinkedChild y) { - if (x.Type == y.Type) + if (x.Type != y.Type) { - return _fileSystem.AreEqual(x.Path, y.Path); + return false; } - return false; + // Compare by ItemId first (preferred) + if (x.ItemId.HasValue && y.ItemId.HasValue) + { + return x.ItemId.Value.Equals(y.ItemId.Value); + } + +#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy comparison + // Fall back to Path comparison for shortcuts and legacy data + return _fileSystem.AreEqual(x.Path, y.Path); +#pragma warning restore CS0618 } public int GetHashCode(LinkedChild obj) { + // Use ItemId for hash if available, otherwise fall back to legacy fields + if (obj.ItemId.HasValue && !obj.ItemId.Value.Equals(Guid.Empty)) + { + return HashCode.Combine(obj.ItemId.Value, obj.Type); + } + +#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy hashing return ((obj.Path ?? string.Empty) + (obj.LibraryItemId ?? string.Empty) + obj.Type).GetHashCode(StringComparison.Ordinal); +#pragma warning restore CS0618 } } } diff --git a/MediaBrowser.Controller/Entities/LinkedChildType.cs b/MediaBrowser.Controller/Entities/LinkedChildType.cs index 3bd260a102..5ce66a561f 100644 --- a/MediaBrowser.Controller/Entities/LinkedChildType.cs +++ b/MediaBrowser.Controller/Entities/LinkedChildType.cs @@ -13,6 +13,16 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Shortcut linked child. /// </summary> - Shortcut = 1 + Shortcut = 1, + + /// <summary> + /// Local alternate version (same item, different file path). + /// </summary> + LocalAlternateVersion = 2, + + /// <summary> + /// Linked alternate version (different item ID). + /// </summary> + LinkedAlternateVersion = 3 } } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 3999c3e076..8216937cad 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -37,9 +37,7 @@ namespace MediaBrowser.Controller.Entities.Movies /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the display order. @@ -160,25 +158,68 @@ namespace MediaBrowser.Controller.Entities.Movies return base.IsVisible(user, skipAllowedTagsCheck); } - if (base.IsVisible(user, skipAllowedTagsCheck)) + if (!IsParentalAllowed(user, skipAllowedTagsCheck)) { - if (LinkedChildren.Length == 0) - { - return true; - } + return false; + } + + if (LinkedChildren.Length == 0) + { + return true; + } + + var userLibraryFolderIds = GetLibraryFolderIds(user); + var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds(); + + if (libraryFolderIds.Length == 0) + { + return true; + } - var userLibraryFolderIds = GetLibraryFolderIds(user); - var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds(); + if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i))) + { + return false; + } - if (libraryFolderIds.Length == 0) + // If user has parental controls, hide the BoxSet when all children are restricted + if (user.MaxParentalRatingScore.HasValue) + { + var linkedItems = GetLinkedChildren(); + if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true))) { - return true; + return false; } + } + + return true; + } + + public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition) + { + if (IsLegacyBoxSet) + { + base.MarkPlayed(user, datePlayed, resetPosition); + return; + } + + foreach (var item in GetLinkedChildren(user)) + { + item.MarkPlayed(user, datePlayed, resetPosition); + } + } - return userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i)); + public override void MarkUnplayed(User user) + { + if (IsLegacyBoxSet) + { + base.MarkUnplayed(user); + return; } - return false; + foreach (var item in GetLinkedChildren(user)) + { + item.MarkUnplayed(user); + } } public override bool IsVisibleStandalone(User user) diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 710b05e7f9..e8817a29cf 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -4,13 +4,15 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities.Movies { @@ -28,9 +30,7 @@ namespace MediaBrowser.Controller.Entities.Movies /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the name of the TMDb collection. 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 6bdba36f9c..42e4f79942 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -28,9 +28,7 @@ namespace MediaBrowser.Controller.Entities.TV /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the season in which it aired. @@ -155,6 +153,12 @@ namespace MediaBrowser.Controller.Entities.TV return 16.0 / 9; } + /// <inheritdoc /> + public override string GetInheritedOriginalLanguage() + { + return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage(); + } + public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index b972ebaa6b..e96ed05a5e 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -128,6 +128,12 @@ namespace MediaBrowser.Controller.Entities.TV return result; } + /// <inheritdoc /> + public override string GetInheritedOriginalLanguage() + { + return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage(); + } + public override string CreatePresentationUniqueKey() { if (IndexNumber.HasValue) @@ -175,9 +181,7 @@ namespace MediaBrowser.Controller.Entities.TV var user = query.User; - Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - - var items = GetEpisodes(user, query.DtoOptions, true).Where(filter); + var items = UserViewBuilder.Filter(GetEpisodes(user, query.DtoOptions, true), user, query, UserDataManager, LibraryManager); return PostFilterAndSort(items, query); } @@ -201,12 +205,17 @@ namespace MediaBrowser.Controller.Entities.TV public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes) { + if (series is null) + { + return []; + } + return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes); } public List<BaseItem> GetEpisodes() { - return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true); + return GetEpisodes(Series, null, null, new DtoOptions(true), true); } public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 6a26ecaebe..952187c6e1 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -52,9 +52,7 @@ namespace MediaBrowser.Controller.Entities.TV /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the display order. diff --git a/MediaBrowser.Controller/Entities/TagExtensions.cs b/MediaBrowser.Controller/Entities/TagExtensions.cs index c1e4d1db2f..07c2298fce 100644 --- a/MediaBrowser.Controller/Entities/TagExtensions.cs +++ b/MediaBrowser.Controller/Entities/TagExtensions.cs @@ -15,6 +15,7 @@ namespace MediaBrowser.Controller.Entities throw new ArgumentNullException(nameof(name)); } + name = name.Trim(); var current = item.Tags; if (!current.Contains(name, StringComparison.OrdinalIgnoreCase)) @@ -25,7 +26,7 @@ namespace MediaBrowser.Controller.Entities } else { - item.Tags = [..current, name]; + item.Tags = [.. current, 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 bed7554b19..c57ed2faf8 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -16,9 +16,7 @@ using MediaBrowser.Controller.TV; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; -using Episode = MediaBrowser.Controller.Entities.TV.Episode; using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; -using Series = MediaBrowser.Controller.Entities.TV.Series; namespace MediaBrowser.Controller.Entities { @@ -63,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); @@ -140,7 +141,7 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; } return parent.QueryRecursive(query); @@ -165,7 +166,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -176,7 +177,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -187,7 +188,18 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; + + 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); } @@ -198,7 +210,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -206,7 +218,7 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetMovieCollections(User user, InternalItemsQuery query) { query.Parent = null; - query.IncludeItemTypes = new[] { BaseItemKind.BoxSet }; + query.IncludeItemTypes = [BaseItemKind.BoxSet]; query.SetUser(user); query.Recursive = true; @@ -215,25 +227,25 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetMovieLatest(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return ConvertToResult(_libraryManager.GetItemList(query)); } private QueryResult<BaseItem> GetMovieResume(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.IsResumable = true; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -247,7 +259,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { BaseItemKind.Movie }, + IncludeItemTypes = [BaseItemKind.Movie], Recursive = true, EnableTotalRecordCount = false }).Items @@ -275,10 +287,10 @@ namespace MediaBrowser.Controller.Entities { query.Recursive = true; query.Parent = queryParent; - query.GenreIds = new[] { displayParent.Id }; + query.GenreIds = [displayParent.Id]; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -292,12 +304,12 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] - { + query.IncludeItemTypes = + [ BaseItemKind.Series, BaseItemKind.Season, BaseItemKind.Episode - }; + ]; } return parent.QueryRecursive(query); @@ -319,12 +331,12 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvLatest(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; query.IsVirtualItem = false; return ConvertToResult(_libraryManager.GetItemList(query)); @@ -332,7 +344,7 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvNextUp(Folder parent, InternalItemsQuery query) { - var parentFolders = GetMediaFolders(parent, query.User, new[] { CollectionType.tvshows }); + var parentFolders = GetMediaFolders(parent, query.User, [CollectionType.tvshows]); var result = _tvSeriesManager.GetNextUp( new NextUpQuery @@ -349,13 +361,13 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvResume(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.IsResumable = true; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -366,7 +378,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -375,7 +387,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { BaseItemKind.Series }, + IncludeItemTypes = [BaseItemKind.Series], Recursive = true, EnableTotalRecordCount = false }).Items @@ -403,10 +415,10 @@ namespace MediaBrowser.Controller.Entities { query.Recursive = true; query.Parent = queryParent; - query.GenreIds = new[] { displayParent.Id }; + query.GenreIds = [displayParent.Id]; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -416,29 +428,54 @@ namespace MediaBrowser.Controller.Entities InternalItemsQuery query) where T : BaseItem { - items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager)); + var filtered = Filter(items, query.User, query, _userDataManager, _libraryManager); - return PostFilterAndSort(items, null, query, _libraryManager); - } - - public static bool FilterItem(BaseItem item, InternalItemsQuery query) - { - return Filter(item, query.User, query, BaseItem.UserDataManager, BaseItem.LibraryManager); + return SortAndPage(filtered, null, query, _libraryManager); } - public static QueryResult<BaseItem> PostFilterAndSort( + /// <summary> + /// Batch-aware filter that applies per-item checks. + /// </summary> + /// <param name="items">The items to filter.</param> + /// <param name="user">The user for filtering context.</param> + /// <param name="query">The query parameters.</param> + /// <param name="userDataManager">The user data manager.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The filtered items.</returns> + public static IEnumerable<BaseItem> Filter( IEnumerable<BaseItem> items, - int? totalRecordLimit, + User user, InternalItemsQuery query, + IUserDataManager userDataManager, ILibraryManager libraryManager) { - // This must be the last filter - if (!query.AdjacentTo.IsNullOrEmpty()) + var filtered = items.Where(i => Filter(i, user, query, userDataManager, libraryManager)); + + if (query.IsPlayed.HasValue && user is not null) { - items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); + var itemList = filtered.ToList(); + var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList(); + + if (folderIds.Count > 0) + { + var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); + var isPlayedValue = query.IsPlayed.Value; + + return itemList.Where(i => + { + if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) + { + return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; + } + + return true; + }); + } + + return itemList; } - return SortAndPage(items, totalRecordLimit, query, libraryManager); + return filtered; } public static QueryResult<BaseItem> SortAndPage( @@ -470,7 +507,12 @@ namespace MediaBrowser.Controller.Entities itemsArray); } - public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager) + private static bool Filter( + BaseItem item, + User user, + InternalItemsQuery query, + IUserDataManager userDataManager, + ILibraryManager libraryManager) { if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)) { @@ -558,35 +600,17 @@ namespace MediaBrowser.Controller.Entities if (query.IsPlayed.HasValue) { - userData ??= userDataManager.GetUserData(user, item); - if (item.IsPlayed(user, userData) != query.IsPlayed.Value) - { - return false; - } - } - - // Filter by Video3DFormat - if (query.Is3D.HasValue) - { - var val = query.Is3D.Value; - var video = item as Video; - - if (video is null || val != video.Video3DFormat.HasValue) + // Folder.IsPlayed() hits the DB per-item (N+1 queries). + // Folders are batch-filtered by the collection Filter() overload. + if (!item.IsFolder) { - return false; - } - } - - /* - * fuck - fix this - if (query.IsHD.HasValue) - { - if (item.IsHD != query.IsHD.Value) - { - return false; + userData ??= userDataManager.GetUserData(user, item); + if (item.IsPlayed(user, userData) != query.IsPlayed.Value) + { + return false; + } } } - */ if (query.IsLocked.HasValue) { @@ -645,68 +669,6 @@ namespace MediaBrowser.Controller.Entities } } - if (query.HasOfficialRating.HasValue) - { - var filterValue = query.HasOfficialRating.Value; - - var hasValue = !string.IsNullOrEmpty(item.OfficialRating); - - if (hasValue != filterValue) - { - return false; - } - } - - if (query.IsPlaceHolder.HasValue) - { - var filterValue = query.IsPlaceHolder.Value; - - var isPlaceHolder = false; - - if (item is ISupportsPlaceHolders hasPlaceHolder) - { - isPlaceHolder = hasPlaceHolder.IsPlaceHolder; - } - - if (isPlaceHolder != filterValue) - { - return false; - } - } - - if (query.HasSpecialFeature.HasValue) - { - var filterValue = query.HasSpecialFeature.Value; - - if (item is IHasSpecialFeatures movie) - { - var ok = filterValue - ? movie.SpecialFeatureIds.Count > 0 - : movie.SpecialFeatureIds.Count == 0; - - if (!ok) - { - return false; - } - } - else - { - return false; - } - } - - if (query.HasSubtitles.HasValue) - { - var val = query.HasSubtitles.Value; - - var video = item as Video; - - if (video is null || val != video.HasSubtitles) - { - return false; - } - } - if (query.HasParentalRating.HasValue) { var val = query.HasParentalRating.Value; @@ -734,66 +696,12 @@ namespace MediaBrowser.Controller.Entities } } - if (query.HasTrailer.HasValue) - { - var val = query.HasTrailer.Value; - var trailerCount = 0; - - if (item is IHasTrailers hasTrailers) - { - trailerCount = hasTrailers.GetTrailerCount(); - } - - var ok = val ? trailerCount > 0 : trailerCount == 0; - - if (!ok) - { - return false; - } - } - - if (query.HasThemeSong.HasValue) - { - var filterValue = query.HasThemeSong.Value; - - var themeCount = item.GetThemeSongs(user).Count; - var ok = filterValue ? themeCount > 0 : themeCount == 0; - - if (!ok) - { - return false; - } - } - - if (query.HasThemeVideo.HasValue) - { - var filterValue = query.HasThemeVideo.Value; - - var themeCount = item.GetThemeVideos(user).Count; - var ok = filterValue ? themeCount > 0 : themeCount == 0; - - if (!ok) - { - return false; - } - } - // Apply genre filter if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparison.OrdinalIgnoreCase))) { return false; } - // Filter by VideoType - if (query.VideoTypes.Length > 0) - { - var video = item as Video; - if (video is null || !query.VideoTypes.Contains(video.VideoType)) - { - return false; - } - } - if (query.ImageTypes.Length > 0 && !query.ImageTypes.Any(item.HasImage)) { return false; @@ -912,30 +820,6 @@ namespace MediaBrowser.Controller.Entities } } - if (query.SeriesStatuses.Length > 0) - { - var ok = new[] { item }.OfType<Series>().Any(p => p.Status.HasValue && query.SeriesStatuses.Contains(p.Status.Value)); - if (!ok) - { - return false; - } - } - - if (query.AiredDuringSeason.HasValue) - { - var episode = item as Episode; - - if (episode is null) - { - return false; - } - - if (!Series.FilterEpisodesBySeason(new[] { episode }, query.AiredDuringSeason.Value, true).Any()) - { - return false; - } - } - if (query.ExcludeItemIds.Contains(item.Id)) { return false; @@ -989,7 +873,7 @@ namespace MediaBrowser.Controller.Entities return GetMediaFolders(user, viewTypes); } - return new BaseItem[] { parent }; + return [parent]; } private UserView GetUserViewWithName(CollectionType? type, string sortName, BaseItem parent) diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 1043029c6e..0606fe1870 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -10,6 +10,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; @@ -19,6 +20,7 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities { @@ -32,15 +34,15 @@ 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] - public string PrimaryVersionId { get; set; } + public Guid? PrimaryVersionId { get; set; } public string[] AdditionalParts { get; set; } @@ -160,7 +162,7 @@ namespace MediaBrowser.Controller.Entities public bool IsStacked => AdditionalParts.Length > 0; [JsonIgnore] - public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0; + public override bool HasLocalAlternateVersions => LibraryManager.GetLocalAlternateVersionIds(this).Any(); public static IRecordingsManager RecordingsManager { get; set; } @@ -252,15 +254,18 @@ namespace MediaBrowser.Controller.Entities private int GetMediaSourceCount(HashSet<Guid> callstack = null) { - callstack ??= new(); - if (!string.IsNullOrEmpty(PrimaryVersionId)) + callstack ??= []; + if (PrimaryVersionId.HasValue) { - var item = LibraryManager.GetItemById(PrimaryVersionId); + var item = LibraryManager.GetItemById(PrimaryVersionId.Value); if (item is Video video) { if (callstack.Contains(video.Id)) { - return video.LinkedAlternateVersions.Length + video.LocalAlternateVersions.Length + 1; + // Count alternate versions using LibraryManager + var linkedCount = LibraryManager.GetLinkedAlternateVersions(video).Count(); + var localCount = LibraryManager.GetLocalAlternateVersionIds(video).Count(); + return linkedCount + localCount + 1; } callstack.Add(video.Id); @@ -268,7 +273,21 @@ namespace MediaBrowser.Controller.Entities } } - return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1; + // Count alternate versions using LibraryManager + var linkedVersionCount = LibraryManager.GetLinkedAlternateVersions(this).Count(); + var localVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count(); + return linkedVersionCount + localVersionCount + 1; + } + + /// <inheritdoc /> + public override string GetInheritedOriginalLanguage() + { + if (ExtraType.GetValueOrDefault() == Model.Entities.ExtraType.Trailer) + { + return GetOwner()?.GetInheritedOriginalLanguage(); + } + + return OriginalLanguage ?? GetOwner()?.GetInheritedOriginalLanguage(); } public override List<string> GetUserDataKeys() @@ -310,25 +329,113 @@ namespace MediaBrowser.Controller.Entities return list; } - public void SetPrimaryVersionId(string id) + public void SetPrimaryVersionId(Guid? id) + { + PrimaryVersionId = id; + 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) { - if (string.IsNullOrEmpty(id)) + 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) { - PrimaryVersionId = null; + return; } - else + + foreach (var (item, _) in GetAllItemsForMediaSources()) { - PrimaryVersionId = id; + 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); + } } + } - PresentationUniqueKey = CreatePresentationUniqueKey(); + /// <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 (!string.IsNullOrEmpty(PrimaryVersionId)) + if (PrimaryVersionId.HasValue) { - return PrimaryVersionId; + return PrimaryVersionId.Value.ToString("N", CultureInfo.InvariantCulture); } return base.CreatePresentationUniqueKey(); @@ -364,11 +471,6 @@ namespace MediaBrowser.Controller.Entities return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); } - public IEnumerable<Guid> GetLocalAlternateVersionIds() - { - return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); - } - private string GetUserDataKey(string providerId) { var key = providerId + "-" + ExtraType.ToString().ToLowerInvariant(); @@ -382,25 +484,16 @@ namespace MediaBrowser.Controller.Entities return key; } - public IEnumerable<Video> GetLinkedAlternateVersions() - { - return LinkedAlternateVersions - .Select(GetLinkedChild) - .Where(i => i is not null) - .OfType<Video>() - .OrderBy(i => i.SortName); - } - /// <summary> /// Gets the additional parts. /// </summary> + /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> /// <returns>IEnumerable{Video}.</returns> - public IOrderedEnumerable<Video> GetAdditionalParts() + public IOrderedEnumerable<Video> GetAdditionalParts(User user = null) { return GetAdditionalPartIds() - .Select(i => LibraryManager.GetItemById(i)) - .Where(i => i is not null) - .OfType<Video>() + .Select(i => LibraryManager.GetItemById<Video>(i)) + .Where(i => i is not null && (user is null || i.IsVisible(user))) .OrderBy(i => i.SortName); } @@ -436,10 +529,21 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + // Clean up LocalAlternateVersions - remove paths that no longer exist + if (LocalAlternateVersions.Length > 0) + { + var validPaths = LocalAlternateVersions.Where(FileSystem.FileExists).ToArray(); + if (validPaths.Length != LocalAlternateVersions.Length) + { + LocalAlternateVersions = validPaths; + hasChanges = true; + } + } + if (IsStacked) { var tasks = AdditionalParts - .Select(i => RefreshMetadataForOwnedVideo(options, true, i, cancellationToken)); + .Select(i => RefreshMetadataForOwnedVideo(options, true, i, typeof(Video), cancellationToken)); await Task.WhenAll(tasks).ConfigureAwait(false); } @@ -449,30 +553,134 @@ namespace MediaBrowser.Controller.Entities // The additional parts won't have additional parts themselves if (IsFileProtocol && SupportsOwnedItems) { - if (!IsStacked) - { - RefreshLinkedAlternateVersions(); + // Check if LinkedChildren are in sync before processing + var existingVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count(); + var tasks = LocalAlternateVersions + .Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken)); - var tasks = LocalAlternateVersions - .Select(i => RefreshMetadataForOwnedVideo(options, false, i, cancellationToken)); + await Task.WhenAll(tasks).ConfigureAwait(false); - await Task.WhenAll(tasks).ConfigureAwait(false); + if (existingVersionCount != LocalAlternateVersions.Length) + { + hasChanges = true; } } return hasChanges; } - private void RefreshLinkedAlternateVersions() + private async Task RefreshMetadataForVersions( + MetadataRefreshOptions options, + bool copyTitleMetadata, + string path, + CancellationToken cancellationToken) { - foreach (var child in LinkedAlternateVersions) + // Ensure the alternate version exists with the correct type (e.g. Movie, not Video) + // before refreshing. This must happen here rather than in RefreshMetadataForOwnedVideo + // because that method is also used for stacked parts which should keep their resolved type. + var id = LibraryManager.GetNewItemId(path, GetType()); + if (LibraryManager.GetItemById(id) is not Video && FileSystem.FileExists(path)) + { + var parentFolder = GetParent() as Folder; + var collectionType = LibraryManager.GetContentType(this); + var altVideo = LibraryManager.ResolveAlternateVersion(path, GetType(), parentFolder, collectionType); + if (altVideo is not null) + { + altVideo.OwnerId = Id; + altVideo.SetPrimaryVersionId(Id); + LibraryManager.CreateItem(altVideo, GetParent()); + } + } + + await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false); + + // Create LinkedChild entry for this local alternate version + // This ensures the relationship exists in the database even if the alternate version + // was created after the primary video was first saved + if (LibraryManager.GetItemById(id) is Video video) + { + LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion); + + // Ensure PrimaryVersionId is set for existing alternate versions that may not have it + if (!video.PrimaryVersionId.HasValue) + { + video.SetPrimaryVersionId(Id); + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + } + + private new Task RefreshMetadataForOwnedVideo( + MetadataRefreshOptions options, + bool copyTitleMetadata, + string path, + CancellationToken cancellationToken) + => RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, GetType(), cancellationToken); + + private async Task RefreshMetadataForOwnedVideo( + MetadataRefreshOptions options, + bool copyTitleMetadata, + string path, + Type itemType, + CancellationToken cancellationToken) + { + var newOptions = new MetadataRefreshOptions(options) + { + SearchResult = null + }; + + var id = LibraryManager.GetNewItemId(path, itemType); + + // Check if the file still exists + if (!FileSystem.FileExists(path)) + { + // File was removed - clean up any orphaned database entry + if (LibraryManager.GetItemById(id) is Video orphanedVideo && orphanedVideo.OwnerId.Equals(Id)) + { + Logger.LogInformation("Owned video file no longer exists, removing orphaned item: {Path}", path); + LibraryManager.DeleteItem(orphanedVideo, new DeleteOptions { DeleteFileLocation = false }); + } + + return; + } + + if (LibraryManager.GetItemById(id) is not Video video) { - // Reset the cached value - if (child.ItemId.IsNullOrEmpty()) + var parentFolder = GetParent() as Folder; + var collectionType = LibraryManager.GetContentType(this); + video = LibraryManager.ResolvePath( + FileSystem.GetFileSystemInfo(path), + parentFolder, + collectionType: collectionType) as Video; + + if (video is null) + { + return; + } + + // Ensure parts use the expected base type (e.g. Video, not Movie) + if (video.GetType() != itemType && Activator.CreateInstance(itemType) is Video correctVideo) { - child.ItemId = null; + correctVideo.Path = video.Path; + correctVideo.Name = video.Name; + correctVideo.VideoType = video.VideoType; + correctVideo.ProductionYear = video.ProductionYear; + correctVideo.ExtraType = video.ExtraType; + video = correctVideo; } + + video.Id = id; + video.OwnerId = Id; + LibraryManager.CreateItem(video, parentFolder); + newOptions.ForceSave = true; + } + + if (video.OwnerId.IsEmpty()) + { + video.OwnerId = Id; } + + await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> @@ -480,7 +688,7 @@ namespace MediaBrowser.Controller.Entities { await base.UpdateToRepositoryAsync(updateReason, cancellationToken).ConfigureAwait(false); - var localAlternates = GetLocalAlternateVersionIds() + var localAlternates = LibraryManager.GetLocalAlternateVersionIds(this) .Select(i => LibraryManager.GetItemById(i)) .Where(i => i is not null); @@ -530,37 +738,58 @@ 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() { - var list = new List<(BaseItem, MediaSourceType)> - { - (this, MediaSourceType.Default) - }; - - list.AddRange(GetLinkedAlternateVersions().Select(i => ((BaseItem)i, MediaSourceType.Grouping))); + return GetAllItemsForMediaSources() + .Select(i => i.Item.Id) + .Distinct() + .ToArray(); + } - if (!string.IsNullOrEmpty(PrimaryVersionId)) - { - if (LibraryManager.GetItemById(PrimaryVersionId) is Video primary) - { - var existingIds = list.Select(i => i.Item1.Id).ToList(); - list.Add((primary, MediaSourceType.Grouping)); - list.AddRange(primary.GetLinkedAlternateVersions().Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); - } - } + 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(); - var localAlternates = list - .SelectMany(i => - { - return i.Item1 is Video video ? video.GetLocalAlternateVersionIds() : Enumerable.Empty<Guid>(); - }) + // 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) - .ToList(); + .Select(i => (i, MediaSourceType.Default)); - list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default))); - - return list; + // Deduplicate + return grouped + .Concat(localAlternates) + .DistinctBy(i => i.Item1.Id) + .ToList(); } } } diff --git a/MediaBrowser.Controller/IO/IExternalDataManager.cs b/MediaBrowser.Controller/IO/IExternalDataManager.cs index f69f4586c6..b2eb8fc3f1 100644 --- a/MediaBrowser.Controller/IO/IExternalDataManager.cs +++ b/MediaBrowser.Controller/IO/IExternalDataManager.cs @@ -16,4 +16,11 @@ public interface IExternalDataManager /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken); + + /// <summary> + /// Deletes only the filesystem-side external item data (attachments, subtitles, trickplay, chapter images). + /// Use this when DB-side cleanup is already handled by another code path (e.g. <c>IItemPersistenceService.DeleteItem</c>). + /// </summary> + /// <param name="item">The item.</param> + void DeleteExternalItemFiles(BaseItem item); } diff --git a/MediaBrowser.Controller/IO/IPathManager.cs b/MediaBrowser.Controller/IO/IPathManager.cs index eb67437545..30961c7610 100644 --- a/MediaBrowser.Controller/IO/IPathManager.cs +++ b/MediaBrowser.Controller/IO/IPathManager.cs @@ -22,30 +22,30 @@ public interface IPathManager /// <param name="mediaSourceId">The media source id.</param> /// <param name="streamIndex">The stream index.</param> /// <param name="extension">The subtitle file extension.</param> - /// <returns>The absolute path.</returns> - public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension); + /// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns> + public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension); /// <summary> /// Gets the path to the subtitle file. /// </summary> /// <param name="mediaSourceId">The media source id.</param> - /// <returns>The absolute path.</returns> - public string GetSubtitleFolderPath(string mediaSourceId); + /// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns> + public string? GetSubtitleFolderPath(string mediaSourceId); /// <summary> /// Gets the path to the attachment file. /// </summary> /// <param name="mediaSourceId">The media source id.</param> /// <param name="fileName">The attachmentFileName index.</param> - /// <returns>The absolute path.</returns> - public string GetAttachmentPath(string mediaSourceId, string fileName); + /// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns> + public string? GetAttachmentPath(string mediaSourceId, string fileName); /// <summary> /// Gets the path to the attachment folder. /// </summary> /// <param name="mediaSourceId">The media source id.</param> - /// <returns>The absolute path.</returns> - public string GetAttachmentFolderPath(string mediaSourceId); + /// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns> + public string? GetAttachmentFolderPath(string mediaSourceId); /// <summary> /// Gets the chapter images data path. diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..af49711606 --- /dev/null +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// A local similar items provider that supports batch queries across multiple source items. +/// Implementations share access filtering and entity loading across all sources for better performance. +/// </summary> +public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// <summary> + /// Gets similar items for multiple source items in a single batch. + /// </summary> + /// <param name="sourceItems">The source items to find similar items for.</param> + /// <param name="query">The query options.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Per-source-item results keyed by source item ID.</returns> + Task<Dictionary<Guid, IReadOnlyList<BaseItem>>> GetBatchSimilarItemsAsync( + IReadOnlyList<BaseItem> sourceItems, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IExternalSearchProvider.cs b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs new file mode 100644 index 0000000000..bded8ba3a3 --- /dev/null +++ b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Interface for external search providers that offer enhanced search capabilities. +/// </summary> +public interface IExternalSearchProvider : ISearchProvider +{ + /// <summary> + /// Searches for items matching the query. + /// </summary> + /// <param name="query">The search query.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>Async enumerable of search results with relevance scores.</returns> + new IAsyncEnumerable<SearchResult> SearchAsync( + SearchProviderQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IInternalSearchProvider.cs b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs new file mode 100644 index 0000000000..f87931395d --- /dev/null +++ b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs @@ -0,0 +1,8 @@ +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Marker interface for internal search providers that typically query the local database directly. +/// </summary> +public interface IInternalSearchProvider : ISearchProvider +{ +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index df1c98f3f7..0b64da291c 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -20,6 +20,7 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Genre = MediaBrowser.Controller.Entities.Genre; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; using Person = MediaBrowser.Controller.Entities.Person; namespace MediaBrowser.Controller.Library @@ -58,11 +59,29 @@ namespace MediaBrowser.Controller.Library /// <param name="fileInfo">The file information.</param> /// <param name="parent">The parent.</param> /// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param> + /// <param name="collectionType">The collection type of the library containing this item.</param> /// <returns>BaseItem.</returns> BaseItem? ResolvePath( FileSystemMetadata fileInfo, Folder? parent = null, - IDirectoryService? directoryService = null); + IDirectoryService? directoryService = null, + CollectionType? collectionType = null); + + /// <summary> + /// Resolves a video file as an alternate version of a primary video, ensuring the result + /// has the same concrete type as the primary (e.g. Movie instead of generic Video). + /// Also cleans up any existing item with the wrong type from a previous scan. + /// </summary> + /// <param name="path">The file path of the alternate version.</param> + /// <param name="expectedVideoType">The expected concrete type (same as the primary video).</param> + /// <param name="parent">The parent folder.</param> + /// <param name="collectionType">The collection type of the library.</param> + /// <returns>A correctly-typed Video, or null if resolution fails.</returns> + Video? ResolveAlternateVersion( + string path, + Type expectedVideoType, + Folder? parent, + CollectionType? collectionType); /// <summary> /// Resolves a set of files into a list of BaseItem. @@ -158,6 +177,13 @@ namespace MediaBrowser.Controller.Library /// <returns>Task.</returns> Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false); + /// <summary> + /// Clears the cached ignore rule directory lookups. + /// Call this before triggering a library scan or item refresh to ensure + /// any changes to .ignore files are picked up. + /// </summary> + void ClearIgnoreRuleCache(); + Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false); /// <summary> @@ -214,6 +240,30 @@ namespace MediaBrowser.Controller.Library Task<IEnumerable<Video>> GetIntros(BaseItem item, User user); /// <summary> + /// Gets the IDs of local alternate versions for a video. + /// Local alternate versions are alternate quality versions at different file paths. + /// </summary> + /// <param name="video">The video item.</param> + /// <returns>Enumerable of alternate version item IDs.</returns> + IEnumerable<Guid> GetLocalAlternateVersionIds(Video video); + + /// <summary> + /// Gets the linked alternate versions for a video. + /// Linked alternate versions are different items representing the same content (e.g., Director's Cut). + /// </summary> + /// <param name="video">The video item.</param> + /// <returns>Enumerable of linked Video items.</returns> + IEnumerable<Video> GetLinkedAlternateVersions(Video video); + + /// <summary> + /// Creates or updates a LinkedChild entry linking a parent to a child item. + /// </summary> + /// <param name="parentId">The parent item ID.</param> + /// <param name="childId">The child item ID.</param> + /// <param name="childType">The type of linked child relationship.</param> + void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType); + + /// <summary> /// Adds the parts. /// </summary> /// <param name="rules">The rules.</param> @@ -348,8 +398,9 @@ namespace MediaBrowser.Controller.Library /// Deletes items that are not having any children like Actors. /// </summary> /// <param name="items">Items to delete.</param> + /// <param name="deleteSourceFiles">Whether to delete source media files on disk. Defaults to false.</param> /// <remarks>In comparison to <see cref="DeleteItem(BaseItem, DeleteOptions, BaseItem, bool)"/> this method skips a lot of steps assuming there are no children to recusively delete nor does it define the special handling for channels and alike.</remarks> - public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items); + public void DeleteItemsUnsafeFast(IReadOnlyCollection<BaseItem> items, bool deleteSourceFiles = false); /// <summary> /// Deletes the item. @@ -514,7 +565,7 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="query">The query.</param> /// <returns>List<Person>.</returns> - IReadOnlyList<Person> GetPeopleItems(InternalPeopleQuery query); + QueryResult<BaseItem> GetPeopleItems(InternalPeopleQuery query); /// <summary> /// Updates the people. @@ -547,6 +598,14 @@ namespace MediaBrowser.Controller.Library IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query); /// <summary> + /// Gets the distinct people names per item for multiple items. + /// </summary> + /// <param name="itemIds">The item IDs.</param> + /// <param name="personTypes">The person types to include.</param> + /// <returns>A dictionary mapping each item ID to its distinct people names. Items with no matching people are omitted.</returns> + IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes); + + /// <summary> /// Queries the items. /// </summary> /// <param name="query">The query.</param> @@ -601,6 +660,20 @@ namespace MediaBrowser.Controller.Library IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff); /// <summary> + /// Gets next up episodes for multiple series in a single batched query. + /// </summary> + /// <param name="query">The query filter.</param> + /// <param name="seriesKeys">The series presentation unique keys to query.</param> + /// <param name="includeSpecials">Whether to include specials for aired episode order sorting.</param> + /// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param> + /// <returns>A dictionary mapping series key to batch result.</returns> + IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery query, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching); + + /// <summary> /// Gets the items result. /// </summary> /// <param name="query">The query.</param> @@ -649,6 +722,42 @@ namespace MediaBrowser.Controller.Library ItemCounts GetItemCounts(InternalItemsQuery query); + /// <summary> + /// Gets item counts for a "by-name" item using an optimized query path. + /// </summary> + /// <param name="kind">The kind of the name item.</param> + /// <param name="id">The ID of the name item.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="user">The user for access filtering.</param> + /// <returns>The item counts grouped by type.</returns> + ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user); + + /// <summary> + /// Batch-fetches child counts for multiple parent folders. + /// Returns the count of immediate children (non-recursive) for each parent. + /// </summary> + /// <param name="parentIds">The list of parent folder IDs.</param> + /// <param name="userId">The user ID for access filtering.</param> + /// <returns>Dictionary mapping parent ID to child count.</returns> + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); + + /// <summary> + /// Batch-fetches played and total counts for multiple folder items. + /// Avoids N+1 queries when building DTOs for lists of folder items. + /// </summary> + /// <param name="folderIds">The list of folder item IDs.</param> + /// <param name="user">The user for access filtering and played status.</param> + /// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns> + Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user); + + /// <summary> + /// Configures the query with user access settings including TopParentIds for library access. + /// Call this before passing a query to methods that need user access filtering. + /// </summary> + /// <param name="query">The query to configure.</param> + /// <param name="user">The user to configure access for.</param> + void ConfigureUserAccess(InternalItemsQuery query, User user); + Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason); BaseItem GetParentItem(Guid? parentId, Guid? userId); @@ -667,5 +776,28 @@ namespace MediaBrowser.Controller.Library /// <param name="virtualFolderPath">The path to the virtualfolder.</param> /// <param name="pathInfo">The new virtualfolder.</param> public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo); + + /// <summary> + /// Re-routes LinkedChildren references from one child to another. + /// Used when video versions change to maintain playlist/BoxSet integrity. + /// </summary> + /// <param name="fromChildId">The child ID to re-route from.</param> + /// <param name="toChildId">The child ID to re-route to.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + Task RerouteLinkedChildReferencesAsync(Guid fromChildId, Guid toChildId); + + /// <summary> + /// Gets legacy query filters for filtering UI. + /// </summary> + /// <param name="query">The query filter.</param> + /// <returns>Aggregated filter values.</returns> + QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); + + /// <summary> + /// Gets a list of all language codes of the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The stream type.</param> + /// <returns>List of language codes.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); } } diff --git a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..b8e41ec810 --- /dev/null +++ b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Provides similar items from the local library. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// </summary> +public interface ILocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// <summary> + /// Determines whether the provider can handle items of the specified type. + /// </summary> + /// <param name="itemType">The item type.</param> + /// <returns><c>true</c> if the provider handles this item type; otherwise <c>false</c>.</returns> + bool Supports(Type itemType); + + /// <summary> + /// Gets similar items from the local library. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions, etc.).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>The list of similar items from the library.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + +/// <summary> +/// Provides similar items from the local library for a specific item type. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// </summary> +/// <typeparam name="TItemType">The type of item this provider handles.</typeparam> +public interface ILocalSimilarItemsProvider<TItemType> : ILocalSimilarItemsProvider + where TItemType : BaseItem +{ + /// <summary> + /// Gets similar items from the local library. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions, etc.).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>The list of similar items from the library.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs new file mode 100644 index 0000000000..3803e51769 --- /dev/null +++ b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Provides similar item references from remote/external sources. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// </summary> +public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider +{ + /// <summary> + /// Determines whether the provider can handle items of the specified type. + /// </summary> + /// <param name="itemType">The item type.</param> + /// <returns><c>true</c> if the provider handles this item type; otherwise <c>false</c>.</returns> + bool Supports(Type itemType); + + /// <summary> + /// Gets similar item references from an external source as an async stream. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async enumerable of similar item references.</returns> + IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + +/// <summary> +/// Provides similar item references from remote/external sources for a specific item type. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// </summary> +/// <typeparam name="TItemType">The type of item this provider handles.</typeparam> +public interface IRemoteSimilarItemsProvider<TItemType> : IRemoteSimilarItemsProvider + where TItemType : BaseItem +{ + /// <summary> + /// Gets similar item references from an external source as an async stream. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async enumerable of similar item references.</returns> + IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); + + bool IRemoteSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + IAsyncEnumerable<SimilarItemReference> IRemoteSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISearchEngine.cs b/MediaBrowser.Controller/Library/ISearchEngine.cs deleted file mode 100644 index 31dcbba5bd..0000000000 --- a/MediaBrowser.Controller/Library/ISearchEngine.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Search; - -namespace MediaBrowser.Controller.Library -{ - /// <summary> - /// Interface ILibrarySearchEngine. - /// </summary> - public interface ISearchEngine - { - /// <summary> - /// Gets the search hints. - /// </summary> - /// <param name="query">The query.</param> - /// <returns>Task{IEnumerable{SearchHintInfo}}.</returns> - QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query); - } -} diff --git a/MediaBrowser.Controller/Library/ISearchManager.cs b/MediaBrowser.Controller/Library/ISearchManager.cs new file mode 100644 index 0000000000..4f763829a7 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchManager.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Orchestrates search operations across registered search providers. +/// </summary> +public interface ISearchManager +{ + /// <summary> + /// Searches for items and returns hints suitable for autocomplete/typeahead UI. + /// Results are ordered by relevance score from search providers. + /// </summary> + /// <param name="query">The search query including filters and pagination.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>Paginated search hints with item metadata for display.</returns> + Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync( + SearchQuery query, + CancellationToken cancellationToken = default); + + /// <summary> + /// Gets ranked search results from registered providers. Returns only item IDs and + /// relevance scores; callers are responsible for loading items and applying user-access filtering. + /// </summary> + /// <param name="query">The search provider query with type/media filters.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>Search results containing item IDs and relevance scores.</returns> + Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync( + SearchProviderQuery query, + CancellationToken cancellationToken = default); + + /// <summary> + /// Registers search providers discovered through dependency injection. + /// Called during application startup. + /// </summary> + /// <param name="providers">The search providers to register.</param> + void AddParts(IEnumerable<ISearchProvider> providers); + + /// <summary> + /// Gets all registered search providers ordered by priority. + /// </summary> + /// <returns>The list of search providers including the SQL fallback provider.</returns> + IReadOnlyList<ISearchProvider> GetProviders(); +} diff --git a/MediaBrowser.Controller/Library/ISearchProvider.cs b/MediaBrowser.Controller/Library/ISearchProvider.cs new file mode 100644 index 0000000000..3b300ed38b --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Interface for search providers. +/// </summary> +public interface ISearchProvider +{ + /// <summary> + /// Gets the name of the provider. + /// </summary> + string Name { get; } + + /// <summary> + /// Gets the type of the provider. + /// </summary> + MetadataPluginType Type { get; } + + /// <summary> + /// Gets the priority of the provider. Lower values execute first. + /// </summary> + int Priority { get; } + + /// <summary> + /// Searches for items matching the query. + /// </summary> + /// <param name="query">The search query.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>Ranked list of candidate item IDs with scores.</returns> + Task<IReadOnlyList<SearchResult>> SearchAsync( + SearchProviderQuery query, + CancellationToken cancellationToken); + + /// <summary> + /// Determines whether this provider can handle the given query. + /// </summary> + /// <param name="query">The search query to evaluate.</param> + /// <returns>True if this provider can search for the query; otherwise, false.</returns> + bool CanSearch(SearchProviderQuery query); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs new file mode 100644 index 0000000000..36fa547eeb --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Interface for managing similar items providers and operations. +/// </summary> +public interface ISimilarItemsManager +{ + /// <summary> + /// Registers similar items providers discovered through dependency injection. + /// </summary> + /// <param name="providers">The similar items providers to register.</param> + void AddParts(IEnumerable<ISimilarItemsProvider> providers); + + /// <summary> + /// Gets the similar items providers for a specific item type. + /// </summary> + /// <typeparam name="T">The item type.</typeparam> + /// <returns>The list of similar items providers for that type.</returns> + IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>() + where T : BaseItem; + + /// <summary> + /// Gets similar items for the specified item. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="excludeArtistIds">Artist IDs to exclude from results.</param> + /// <param name="user">The user context.</param> + /// <param name="dtoOptions">The DTO options.</param> + /// <param name="limit">Maximum number of results.</param> + /// <param name="libraryOptions">The library options for provider configuration.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The list of similar items.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList<Guid> excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken); + + /// <summary> + /// Builds movie recommendations for a user: a mix of similar-items and person-based categories, + /// scheduled round-robin and capped to <paramref name="categoryLimit"/>. + /// </summary> + /// <param name="user">The user the recommendations are for. May be <see langword="null"/> for anonymous access.</param> + /// <param name="parentId">The library/folder to localize the search to. Pass <see cref="Guid.Empty"/> to use the root.</param> + /// <param name="categoryLimit">Maximum number of recommendation categories to return.</param> + /// <param name="itemLimit">Maximum number of items per category.</param> + /// <param name="dtoOptions">DTO options used when querying the library.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The list of recommendation categories, ordered by <see cref="RecommendationType"/>.</returns> + Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs new file mode 100644 index 0000000000..0d089369a8 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Base marker interface for similar items providers. +/// </summary> +public interface ISimilarItemsProvider +{ + /// <summary> + /// Gets the name of the provider. + /// </summary> + string Name { get; } + + /// <summary> + /// Gets the type of the provider. + /// </summary> + MetadataPluginType Type { get; } + + /// <summary> + /// Gets the cache duration for results from this provider. + /// If null, results will not be cached. + /// </summary> + TimeSpan? CacheDuration => null; +} diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index eb46611dd9..2ee8845346 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -55,6 +55,32 @@ namespace MediaBrowser.Controller.Library UserItemDataDto? GetUserDataDto(BaseItem item, User user); /// <summary> + /// Gets user data for multiple items in a single batch operation. + /// </summary> + /// <param name="items">The items to get user data for.</param> + /// <param name="user">The user.</param> + /// <returns>A dictionary mapping item IDs to their user data.</returns> + Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user); + + /// <summary> + /// Gets the user data that should drive resume for a multi-version item: the data of the most + /// recently played alternate version (including the item itself) that has a resume point. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <returns>The resume version's data, or <c>null</c> when the item has no versions or none has a resume point.</returns> + VersionResumeData? GetResumeUserData(User user, BaseItem item); + + /// <summary> + /// Gets the resume-driving user data for multiple items in a single batch operation. + /// See <see cref="GetResumeUserData(User, BaseItem)"/>. + /// </summary> + /// <param name="items">The items to get resume data for.</param> + /// <param name="user">The user.</param> + /// <returns>A dictionary mapping item ids to their resume version's data; items without one are omitted.</returns> + IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user); + + /// <summary> /// Gets the user data dto. /// </summary> /// <param name="item">Item to use.</param> @@ -72,5 +98,13 @@ namespace MediaBrowser.Controller.Library /// <param name="reportedPositionTicks">New playstate.</param> /// <returns>True if playstate was updated.</returns> bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks); + + /// <summary> + /// Clears any stored audio and subtitle stream selections for the given user/item pair. + /// Used when the user has opted out of remembering selections. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + void ResetPlaybackStreamSelections(User user, BaseItem item); } } diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 0109cf4b7d..e2b54ea7a7 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the users. /// </summary> - /// <value>The users.</value> - IEnumerable<User> Users { get; } + /// <returns>The users.</returns> + IEnumerable<User> GetUsers(); /// <summary> /// Gets the user ids. /// </summary> - /// <value>The users ids.</value> - IEnumerable<Guid> UsersIds { get; } + /// <returns>The users ids.</returns> + IEnumerable<Guid> GetUsersIds(); /// <summary> /// Initializes the user manager and ensures that a user exists. @@ -48,6 +48,12 @@ namespace MediaBrowser.Controller.Library User? GetUserById(Guid id); /// <summary> + /// Gets the first available user. + /// </summary> + /// <returns>The first user, or <c>null</c> if no users exist.</returns> + User? GetFirstUser(); + + /// <summary> /// Gets the name of the user by. /// </summary> /// <param name="name">The name.</param> @@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Renames the user. /// </summary> - /// <param name="user">The user.</param> + /// <param name="userId">The UserId to change.</param> + /// <param name="oldName">The old Username.</param> /// <param name="newName">The new name.</param> /// <returns>Task.</returns> /// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception> /// <exception cref="ArgumentException">If the provided user doesn't exist.</exception> - Task RenameUser(User user, string newName); + Task RenameUser(Guid userId, string oldName, string newName); /// <summary> /// Updates the user. @@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Resets the password. /// </summary> - /// <param name="user">The user.</param> + /// <param name="userId">The users Id.</param> /// <returns>Task.</returns> - Task ResetPassword(User user); + Task ResetPassword(Guid userId); /// <summary> /// Changes the password. /// </summary> - /// <param name="user">The user.</param> + /// <param name="userId">The users id.</param> /// <param name="newPassword">New password to use.</param> /// <returns>Awaitable task.</returns> - Task ChangePassword(User user, string newPassword); + Task ChangePassword(Guid userId, string newPassword); /// <summary> /// Gets the user dto. diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs index b558ef73d5..c5e7ae4913 100644 --- a/MediaBrowser.Controller/Library/ItemResolveArgs.cs +++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs @@ -117,7 +117,7 @@ namespace MediaBrowser.Controller.Library get { var paths = string.IsNullOrEmpty(Path) ? Array.Empty<string>() : [Path]; - return AdditionalLocations is null ? paths : [..paths, ..AdditionalLocations]; + return AdditionalLocations is null ? paths : [.. paths, .. AdditionalLocations]; } } diff --git a/MediaBrowser.Controller/Library/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs new file mode 100644 index 0000000000..845588c872 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs @@ -0,0 +1,45 @@ +using System; +using Jellyfin.Data.Enums; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Query object for search providers. +/// </summary> +public class SearchProviderQuery +{ + /// <summary> + /// Gets the search term. + /// </summary> + public required string SearchTerm { get; init; } + + /// <summary> + /// Gets the user ID for user-specific searches. + /// </summary> + public Guid? UserId { get; init; } + + /// <summary> + /// Gets the item types to include in the search. + /// </summary> + public BaseItemKind[] IncludeItemTypes { get; init; } = []; + + /// <summary> + /// Gets the item types to exclude from the search. + /// </summary> + public BaseItemKind[] ExcludeItemTypes { get; init; } = []; + + /// <summary> + /// Gets the media types to include in the search. + /// </summary> + public MediaType[] MediaTypes { get; init; } = []; + + /// <summary> + /// Gets the maximum number of results to return. + /// </summary> + public int? Limit { get; init; } + + /// <summary> + /// Gets the parent ID to scope the search. + /// </summary> + public Guid? ParentId { get; init; } +} diff --git a/MediaBrowser.Controller/Library/SearchResult.cs b/MediaBrowser.Controller/Library/SearchResult.cs new file mode 100644 index 0000000000..e6f145e979 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchResult.cs @@ -0,0 +1,60 @@ +using System; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Represents an item matched by a search query with its relevance score. +/// </summary> +public readonly struct SearchResult : IEquatable<SearchResult> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SearchResult"/> struct. + /// </summary> + /// <param name="itemId">The item ID.</param> + /// <param name="score">The relevance score.</param> + public SearchResult(Guid itemId, float score) + { + ItemId = itemId; + Score = score; + } + + /// <summary> + /// Gets the ID of the matching item. + /// </summary> + public Guid ItemId { get; init; } + + /// <summary> + /// Gets the relevance score. Higher values indicate more relevant results. + /// </summary> + public float Score { get; init; } + + /// <summary> + /// Compares two <see cref="SearchResult"/> instances for equality. + /// </summary> + /// <param name="left">The left operand.</param> + /// <param name="right">The right operand.</param> + /// <returns>True if the instances are equal; otherwise, false.</returns> + public static bool operator ==(SearchResult left, SearchResult right) + => left.Equals(right); + + /// <summary> + /// Compares two <see cref="SearchResult"/> instances for inequality. + /// </summary> + /// <param name="left">The left operand.</param> + /// <param name="right">The right operand.</param> + /// <returns>True if the instances are not equal; otherwise, false.</returns> + public static bool operator !=(SearchResult left, SearchResult right) + => !left.Equals(right); + + /// <inheritdoc/> + public override bool Equals(object? obj) + => obj is SearchResult other && Equals(other); + + /// <inheritdoc/> + public bool Equals(SearchResult other) + => ItemId.Equals(other.ItemId) && Score.Equals(other.Score); + + /// <inheritdoc/> + public override int GetHashCode() + => HashCode.Combine(ItemId, Score); +} diff --git a/MediaBrowser.Controller/Library/SimilarItemReference.cs b/MediaBrowser.Controller/Library/SimilarItemReference.cs new file mode 100644 index 0000000000..2a40c93bdd --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemReference.cs @@ -0,0 +1,22 @@ +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// A reference to a similar item by provider ID with a similarity score. +/// </summary> +public class SimilarItemReference +{ + /// <summary> + /// Gets or sets the provider name (e.g., "Tmdb", "MusicBrainzArtist"). + /// </summary> + public required string ProviderName { get; set; } + + /// <summary> + /// Gets or sets the provider ID value. + /// </summary> + public required string ProviderId { get; set; } + + /// <summary> + /// Gets or sets the similarity score (0.0 to 1.0). + /// </summary> + public float? Score { get; set; } +} diff --git a/MediaBrowser.Controller/Library/SimilarItemsQuery.cs b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs new file mode 100644 index 0000000000..1ed3ceec16 --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Query options for similar items requests. +/// </summary> +public class SimilarItemsQuery +{ + /// <summary> + /// Gets or sets the user context. + /// </summary> + public User? User { get; set; } + + /// <summary> + /// Gets or sets the maximum number of results. + /// </summary> + public int? Limit { get; set; } + + /// <summary> + /// Gets or sets the DTO options. + /// </summary> + public DtoOptions? DtoOptions { get; set; } + + /// <summary> + /// Gets or sets the item IDs to exclude from results. + /// </summary> + public IReadOnlyList<Guid> ExcludeItemIds { get; set; } = []; + + /// <summary> + /// Gets or sets the artist IDs to exclude from results. + /// </summary> + public IReadOnlyList<Guid> ExcludeArtistIds { get; set; } = []; +} diff --git a/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs new file mode 100644 index 0000000000..71346fcadf --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// A recommendation category derived from a baseline item, holding similar items prior to DTO conversion. +/// </summary> +public sealed class SimilarItemsRecommendation +{ + /// <summary> + /// Gets the display name of the baseline item the recommendation is based on. + /// </summary> + public required string BaselineItemName { get; init; } + + /// <summary> + /// Gets an identifier for the recommendation category. + /// </summary> + public required Guid CategoryId { get; init; } + + /// <summary> + /// Gets the recommendation type. + /// </summary> + public required RecommendationType RecommendationType { get; init; } + + /// <summary> + /// Gets the similar items for the baseline, ordered by relevance. + /// </summary> + public required IReadOnlyList<BaseItem> Items { get; init; } +} diff --git a/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs new file mode 100644 index 0000000000..1766c50141 --- /dev/null +++ b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Single definition of "which alternate version was most recently played" shared by the resume tile + /// (<see cref="IUserDataManager.GetResumeUserData"/>), the media-source default ordering and Next Up. + /// Each call site declares its own eligibility rule so the intentional differences (resumable-only vs. + /// resumable-or-completed) are visible in one place instead of being re-implemented divergently. + /// The SQL resume query keeps its own translation of the same rule. + /// </summary> + public static class VersionPlaybackSelector + { + /// <summary> + /// Selects the entry whose user data has the greatest <see cref="UserItemData.LastPlayedDate"/>, + /// considering only entries that satisfy <paramref name="isEligible"/>. On an exact tie the first + /// encountered entry wins. + /// </summary> + /// <typeparam name="T">The candidate type (e.g. a version item or a media source).</typeparam> + /// <param name="items">The candidates to choose from.</param> + /// <param name="dataSelector">Resolves the user data for a candidate, or <c>null</c> when it has none.</param> + /// <param name="isEligible">Whether a candidate's user data makes it a valid winner.</param> + /// <returns>The most recently played eligible candidate, or <c>default</c> when none qualify.</returns> + public static T? SelectMostRecentlyPlayed<T>( + IEnumerable<T> items, + Func<T, UserItemData?> dataSelector, + Func<UserItemData, bool> isEligible) + { + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(dataSelector); + ArgumentNullException.ThrowIfNull(isEligible); + + T? winner = default; + var winnerDate = DateTime.MinValue; + var hasWinner = false; + + foreach (var item in items) + { + var data = dataSelector(item); + if (data is null || !isEligible(data)) + { + continue; + } + + var date = data.LastPlayedDate ?? DateTime.MinValue; + if (!hasWinner || date > winnerDate) + { + winner = item; + winnerDate = date; + hasWinner = true; + } + } + + return winner; + } + } +} diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs new file mode 100644 index 0000000000..772e2bf3a7 --- /dev/null +++ b/MediaBrowser.Controller/Library/VersionResumeData.cs @@ -0,0 +1,41 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// The user data of the most recently played alternate version that should drive the completion state of a multi-version item. + /// </summary> + /// <param name="VersionId">The id of the version that owns <paramref name="UserData"/>.</param> + /// <param name="UserData">The resume version's user data.</param> + public record VersionResumeData(Guid VersionId, UserItemData UserData) + { + /// <summary> + /// Merges the most recently played version's completion state into the supplied user data dto. + /// Completion (played) propagates to the primary. An in-progress resume position stays on the version + /// that owns it, which is surfaced directly (e.g. in resume queries) so that playback always targets + /// the correct version rather than resuming the primary at another version's offset. When the movie was + /// finished on a different version, the primary's own stale resume position is cleared so it does not + /// render as "watched and resumable" at the same time. + /// </summary> + /// <param name="dto">The user data dto to update.</param> + public void ApplyTo(UserItemDataDto dto) + { + dto.Played = dto.Played || UserData.Played; + + if ((UserData.LastPlayedDate ?? DateTime.MinValue) > (dto.LastPlayedDate ?? DateTime.MinValue)) + { + dto.LastPlayedDate = UserData.LastPlayedDate; + } + + // A different version was finished (played, no resume position of its own) and is the most + // recently played: the whole movie is watched. + if (!VersionId.Equals(dto.ItemId) && UserData.Played && UserData.PlaybackPositionTicks <= 0) + { + dto.PlaybackPositionTicks = 0; + dto.PlayedPercentage = null; + } + } + } +} diff --git a/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs b/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs new file mode 100644 index 0000000000..6953650952 --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/ISchedulesDirectService.cs @@ -0,0 +1,31 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.LiveTv; + +/// <summary> +/// Provides Schedules Direct specific operations. +/// </summary> +public interface ISchedulesDirectService +{ + /// <summary> + /// Gets the available countries from the Schedules Direct API, using a file cache. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A stream containing the raw JSON response.</returns> + Task<Stream> GetAvailableCountries(CancellationToken cancellationToken); + + /// <summary> + /// Gets a value indicating whether the Schedules Direct daily image download limit is currently active. + /// </summary> + /// <returns><c>true</c> if the image limit has been hit and has not yet reset; otherwise <c>false</c>.</returns> + bool IsImageDailyLimitActive(); + + /// <summary> + /// Gets a value indicating whether the Schedules Direct service is available. + /// Returns <c>false</c> if a permanent account error has occurred or a transient backoff is active. + /// </summary> + /// <returns><c>true</c> if the service can accept requests; otherwise <c>false</c>.</returns> + bool IsServiceAvailable(); +} diff --git a/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs b/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs index 8247066cc9..68e61f3cc4 100644 --- a/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs +++ b/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs @@ -38,6 +38,12 @@ public interface ITunerHostManager IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly); /// <summary> + /// Deletes a tuner host by id, cleans up associated caches, and triggers a guide refresh. + /// </summary> + /// <param name="id">The tuner host id to delete.</param> + void DeleteTunerHost(string? id); + + /// <summary> /// Scans for tuner devices that have changed URLs. /// </summary> /// <param name="cancellationToken">The <see cref="CancellationToken"/> to use.</param> diff --git a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs index b10e77e10a..aee4691cdf 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs @@ -109,7 +109,7 @@ namespace MediaBrowser.Controller.LiveTv { if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number)) { - return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty); + return string.Format(CultureInfo.InvariantCulture, "{0:0000000000.00000}", number) + "-" + (Name ?? string.Empty); } return (Number ?? string.Empty) + "-" + (Name ?? string.Empty); diff --git a/MediaBrowser.Controller/LiveTv/ProgramInfo.cs b/MediaBrowser.Controller/LiveTv/ProgramInfo.cs index 3c3ac2471f..905aad17b9 100644 --- a/MediaBrowser.Controller/LiveTv/ProgramInfo.cs +++ b/MediaBrowser.Controller/LiveTv/ProgramInfo.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -12,45 +10,45 @@ namespace MediaBrowser.Controller.LiveTv { public ProgramInfo() { - Genres = new List<string>(); + Genres = []; - ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + ProviderIds = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase); + SeriesProviderIds = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase); } /// <summary> /// Gets or sets the id of the program. /// </summary> - public string Id { get; set; } + public string? Id { get; set; } /// <summary> /// Gets or sets the channel identifier. /// </summary> /// <value>The channel identifier.</value> - public string ChannelId { get; set; } + public string? ChannelId { get; set; } /// <summary> /// Gets or sets the name of the program. /// </summary> - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the official rating. /// </summary> /// <value>The official rating.</value> - public string OfficialRating { get; set; } + public string? OfficialRating { get; set; } /// <summary> /// Gets or sets the overview. /// </summary> /// <value>The overview.</value> - public string Overview { get; set; } + public string? Overview { get; set; } /// <summary> /// Gets or sets the short overview. /// </summary> /// <value>The short overview.</value> - public string ShortOverview { get; set; } + public string? ShortOverview { get; set; } /// <summary> /// Gets or sets the start date of the program, in UTC. @@ -108,25 +106,25 @@ namespace MediaBrowser.Controller.LiveTv /// Gets or sets the episode title. /// </summary> /// <value>The episode title.</value> - public string EpisodeTitle { get; set; } + public string? EpisodeTitle { get; set; } /// <summary> /// Gets or sets the image path if it can be accessed directly from the file system. /// </summary> /// <value>The image path.</value> - public string ImagePath { get; set; } + public string? ImagePath { get; set; } /// <summary> /// Gets or sets the image url if it can be downloaded. /// </summary> /// <value>The image URL.</value> - public string ImageUrl { get; set; } + public string? ImageUrl { get; set; } - public string ThumbImageUrl { get; set; } + public string? ThumbImageUrl { get; set; } - public string LogoImageUrl { get; set; } + public string? LogoImageUrl { get; set; } - public string BackdropImageUrl { get; set; } + public string? BackdropImageUrl { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance has image. @@ -188,19 +186,19 @@ namespace MediaBrowser.Controller.LiveTv /// Gets or sets the home page URL. /// </summary> /// <value>The home page URL.</value> - public string HomePageUrl { get; set; } + public string? HomePageUrl { get; set; } /// <summary> /// Gets or sets the series identifier. /// </summary> /// <value>The series identifier.</value> - public string SeriesId { get; set; } + public string? SeriesId { get; set; } /// <summary> /// Gets or sets the show identifier. /// </summary> /// <value>The show identifier.</value> - public string ShowId { get; set; } + public string? ShowId { get; set; } /// <summary> /// Gets or sets the season number. @@ -218,10 +216,10 @@ namespace MediaBrowser.Controller.LiveTv /// Gets or sets the etag. /// </summary> /// <value>The etag.</value> - public string Etag { get; set; } + public string? Etag { get; set; } - public Dictionary<string, string> ProviderIds { get; set; } + public Dictionary<string, string?> ProviderIds { get; set; } - public Dictionary<string, string> SeriesProviderIds { get; set; } + public Dictionary<string, string?> SeriesProviderIds { get; set; } } } diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 0025080cc9..06188ad511 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Controller</PackageId> - <VersionPrefix>10.12.0</VersionPrefix> + <VersionPrefix>12.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index 10f2f04af6..34826982af 100644 --- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -92,6 +92,12 @@ namespace MediaBrowser.Controller.MediaEncoding public string CodecTag { get; set; } /// <summary> + /// Gets or sets the rotation. + /// </summary> + /// <value>The video rotation angle, usually 0 or +-90/180.</value> + public string Rotation { get; set; } + + /// <summary> /// Gets or sets the framerate. /// </summary> /// <value>The framerate.</value> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 11eee1a372..1b0bbe9ea0 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -25,6 +25,7 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Configuration; using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; @@ -33,18 +34,18 @@ namespace MediaBrowser.Controller.MediaEncoding public partial class EncodingHelper { /// <summary> - /// The codec validation regex. + /// The codec validation regex string. /// This regular expression matches strings that consist of alphanumeric characters, hyphens, /// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters. /// This should matches all common valid codecs. /// </summary> - public const string ContainerValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$"; + public const string ContainerValidationRegexStr = @"^[a-zA-Z0-9\-\._,|]{0,40}$"; /// <summary> - /// The level validation regex. + /// The level validation regex string. /// This regular expression matches strings representing a double. /// </summary> - public const string LevelValidationRegex = @"-?[0-9]+(?:\.[0-9]+)?"; + public const string LevelValidationRegexStr = @"-?[0-9]+(?:\.[0-9]+)?"; private const string _defaultMjpegEncoder = "mjpeg"; @@ -85,8 +86,8 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1); private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0); private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1); - - private static readonly Regex _containerValidationRegex = new(ContainerValidationRegex, RegexOptions.Compiled); + private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0); + private readonly Version _minFFmpegNoiseBsfDrop = new Version(5, 0); private static readonly string[] _videoProfilesH264 = [ @@ -180,6 +181,22 @@ namespace MediaBrowser.Controller.MediaEncoding RemoveHdr10Plus, } + /// <summary> + /// The codec validation regex. + /// This regular expression matches strings that consist of alphanumeric characters, hyphens, + /// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters. + /// This should matches all common valid codecs. + /// </summary> + [GeneratedRegex(ContainerValidationRegexStr)] + public static partial Regex ContainerValidationRegex(); + + /// <summary> + /// The level validation regex string. + /// This regular expression matches strings representing a double. + /// </summary> + [GeneratedRegex(LevelValidationRegexStr)] + public static partial Regex LevelValidationRegex(); + [GeneratedRegex(@"\s+")] private static partial Regex WhiteSpaceRegex(); @@ -405,7 +422,9 @@ namespace MediaBrowser.Controller.MediaEncoding } return state.VideoStream.VideoRange == VideoRange.HDR - && IsDoviWithHdr10Bl(state.VideoStream); + && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 + || IsHdr10Plus(state.VideoStream) + || IsDoviWithHdr10Bl(state.VideoStream)); } private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options) @@ -420,8 +439,17 @@ namespace MediaBrowser.Controller.MediaEncoding // Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding. // All other HDR formats working. return state.VideoStream.VideoRange == VideoRange.HDR - && (IsDoviWithHdr10Bl(state.VideoStream) - || state.VideoStream.VideoRangeType is VideoRangeType.HLG); + && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 + || IsHdr10Plus(state.VideoStream) + || IsDoviWithHdr10Bl(state.VideoStream) + || state.VideoStream.VideoRangeType == VideoRangeType.HLG); + } + + private static bool IsDeinterlaceAvailable(EncodingJobInfo state) + { + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + return doDeintH264 || doDeintHevc; } private bool IsVideoStreamHevcRext(EncodingJobInfo state) @@ -476,7 +504,7 @@ namespace MediaBrowser.Controller.MediaEncoding return GetMjpegEncoder(state, encodingOptions); } - if (_containerValidationRegex.IsMatch(codec)) + if (ContainerValidationRegex().IsMatch(codec)) { return codec.ToLowerInvariant(); } @@ -517,7 +545,7 @@ namespace MediaBrowser.Controller.MediaEncoding public static string GetInputFormat(string container) { - if (string.IsNullOrEmpty(container) || !_containerValidationRegex.IsMatch(container)) + if (string.IsNullOrEmpty(container) || !ContainerValidationRegex().IsMatch(container)) { return null; } @@ -735,7 +763,7 @@ namespace MediaBrowser.Controller.MediaEncoding { var codec = state.OutputAudioCodec; - if (!_containerValidationRegex.IsMatch(codec)) + if (!ContainerValidationRegex().IsMatch(codec)) { codec = "aac"; } @@ -1248,16 +1276,13 @@ namespace MediaBrowser.Controller.MediaEncoding .Append(_mediaEncoder.GetInputPathArgument(state)); } - // sub2video for external graphical subtitles - if (state.SubtitleStream is not null - && ShouldEncodeSubtitle(state) - && !state.SubtitleStream.IsTextSubtitleStream - && state.SubtitleStream.IsExternal) + if (NeedsExternalSubtitleMuxing(state)) { var subtitlePath = state.SubtitleStream.Path; - var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan()); + var isGraphicalBurnIn = ShouldEncodeSubtitle(state) && !state.SubtitleStream.IsTextSubtitleStream; // dvdsub/vobsub graphical subtitles use .sub+.idx pairs + var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan()); if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) { var idxFile = Path.ChangeExtension(subtitlePath, ".idx"); @@ -1267,6 +1292,20 @@ namespace MediaBrowser.Controller.MediaEncoding } } + // Use analyzeduration also for subtitle streams to improve resolution detection with streams inside MKS files + var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state); + if (!string.IsNullOrEmpty(analyzeDurationArgument)) + { + arg.Append(' ').Append(analyzeDurationArgument); + } + + // Apply probesize, too, if configured + var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg(); + if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument)) + { + arg.Append(' ').Append(ffmpegProbeSizeArgument); + } + // Also seek the external subtitles stream. var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer); if (!string.IsNullOrEmpty(seekSubParam)) @@ -1274,7 +1313,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekSubParam); } - if (!string.IsNullOrEmpty(canvasArgs)) + if (isGraphicalBurnIn && !string.IsNullOrEmpty(canvasArgs)) { arg.Append(canvasArgs); } @@ -1517,20 +1556,61 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer) { - var bitStreamArgs = string.Empty; + var filters = new List<string>(); + + var noiseFilter = GetCopiedAudioTrimBsf(state); + if (!string.IsNullOrEmpty(noiseFilter)) + { + filters.Add(noiseFilter); + } + var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.'); // Apply aac_adtstoasc bitstream filter when media source is in mpegts. if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase) && (string.Equals(mediaSourceContainer, "ts", StringComparison.OrdinalIgnoreCase) || string.Equals(mediaSourceContainer, "aac", StringComparison.OrdinalIgnoreCase) - || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase))) + || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)) + && IsAAC(state.AudioStream)) { - bitStreamArgs = GetBitStreamArgs(state, MediaStreamType.Audio); - bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs; + filters.Add("aac_adtstoasc"); } - return bitStreamArgs; + return filters.Count == 0 + ? string.Empty + : " -bsf:a " + string.Join(',', filters); + } + + // When video is transcoded, accurate_seek (the default) trims video to the + // exact seek point via decoder-side frame discard. But stream-copied audio + // bypasses the decoder, so it starts from the nearest keyframe — potentially + // seconds before the target. Use the noise bsf to drop copied audio packets + // before the seek target, achieving the same trim precision without + // re-encoding. The noise bsf's drop= parameter requires ffmpeg >= 5.0. + // Important: make sure not to use it with wtv because it breaks seeking + private string GetCopiedAudioTrimBsf(EncodingJobInfo state) + { + if (state.TranscodingType is not TranscodingJobType.Hls + || !state.IsVideoRequest + || IsCopyCodec(state.OutputVideoCodec) + || !IsCopyCodec(state.OutputAudioCodec) + || string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase) + || _mediaEncoder.EncoderVersion < _minFFmpegNoiseBsfDrop) + { + return null; + } + + var startTicks = state.BaseRequest.StartTimeTicks ?? 0; + if (startTicks <= 0) + { + return null; + } + + var seekSeconds = startTicks / (double)TimeSpan.TicksPerSecond; + return string.Format( + CultureInfo.InvariantCulture, + "noise=drop='lt(pts*tb\\,{0:F3})'", + seekSeconds); } public static string GetSegmentFileExtension(string segmentContainer) @@ -1552,14 +1632,15 @@ namespace MediaBrowser.Controller.MediaEncoding int bitrate = state.OutputVideoBitrate.Value; - // Bit rate under 1000k is not allowed in h264_qsv + // Bit rate under 1000k is not allowed in h264_qsv. if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) { bitrate = Math.Max(bitrate, 1000); } - // Currently use the same buffer size for all encoders - int bufsize = bitrate * 2; + // Currently use the same buffer size for all non-QSV encoders. + // Use long arithmetic to prevent int32 overflow for very high bitrate values. + int bufsize = (int)Math.Min((long)bitrate * 2, int.MaxValue); if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase)) { @@ -1587,16 +1668,33 @@ namespace MediaBrowser.Controller.MediaEncoding mbbrcOpt = " -mbbrc 1"; } + // Some less powerful H.264 HW decoders require strict CPB size + // So bufsize optimizations should not be applied to them + int factor = 2; + var codec = state.ActualOutputVideoCodec; + var level = state.GetRequestedLevel(codec); + if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase) + && double.TryParse(level, CultureInfo.InvariantCulture, out double requestedLevel) + && requestedLevel < 51) + { + factor = 1; + } + // Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation // Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes - return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {bitrate + 1} -rc_init_occupancy {bitrate * 2} -bufsize {bitrate * 4}"); + // Use long arithmetic and clamp to int.MaxValue to prevent int32 overflow + // (e.g. bitrate * 4 wraps to a negative value for bitrates above ~537 million) + int qsvMaxrate = (int)Math.Min((long)bitrate + 1, int.MaxValue); + int qsvInitOcc = (int)Math.Min((long)bitrate * 1 * factor, int.MaxValue); + int qsvBufsize = (int)Math.Min((long)bitrate * 2 * factor, int.MaxValue); + + return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}"); } if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)) { - // Override the too high default qmin 18 in transcoding preset + // Override the too high default qmin 18 in transcoding preset in legacy h26x_amf return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } @@ -1715,13 +1813,13 @@ namespace MediaBrowser.Controller.MediaEncoding { param += encoderPreset switch { - EncoderPreset.veryslow => " -preset p7", - EncoderPreset.slower => " -preset p6", - EncoderPreset.slow => " -preset p5", - EncoderPreset.medium => " -preset p4", - EncoderPreset.fast => " -preset p3", - EncoderPreset.faster => " -preset p2", - _ => " -preset p1" + EncoderPreset.veryslow => " -preset p7", + EncoderPreset.slower => " -preset p6", + EncoderPreset.slow => " -preset p5", + EncoderPreset.medium => " -preset p4", + EncoderPreset.fast => " -preset p3", + EncoderPreset.faster => " -preset p2", + _ => " -preset p1" }; } else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf) @@ -1731,11 +1829,11 @@ namespace MediaBrowser.Controller.MediaEncoding { param += encoderPreset switch { - EncoderPreset.veryslow => " -quality quality", - EncoderPreset.slower => " -quality quality", - EncoderPreset.slow => " -quality quality", - EncoderPreset.medium => " -quality balanced", - _ => " -quality speed" + EncoderPreset.veryslow => " -quality quality", + EncoderPreset.slower => " -quality quality", + EncoderPreset.slow => " -quality quality", + EncoderPreset.medium => " -quality balanced", + _ => " -quality speed" }; if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) @@ -1755,11 +1853,11 @@ namespace MediaBrowser.Controller.MediaEncoding { param += encoderPreset switch { - EncoderPreset.veryslow => " -prio_speed 0", - EncoderPreset.slower => " -prio_speed 0", - EncoderPreset.slow => " -prio_speed 0", - EncoderPreset.medium => " -prio_speed 0", - _ => " -prio_speed 1" + EncoderPreset.veryslow => " -prio_speed 0", + EncoderPreset.slower => " -prio_speed 0", + EncoderPreset.slow => " -prio_speed 0", + EncoderPreset.medium => " -prio_speed 0", + _ => " -prio_speed 1" }; } @@ -1768,38 +1866,40 @@ namespace MediaBrowser.Controller.MediaEncoding public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level) { - if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel)) + if (!double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel)) + { + return null; + } + + if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)) + // Transcode to level 5.3 (15) and lower for maximum compatibility. + // https://en.wikipedia.org/wiki/AV1#Levels + if (requestLevel < 0 || requestLevel >= 15) { - // Transcode to level 5.3 (15) and lower for maximum compatibility. - // https://en.wikipedia.org/wiki/AV1#Levels - if (requestLevel < 0 || requestLevel >= 15) - { - return "15"; - } + return "15"; } - else if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)) + } + else if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)) + { + // Transcode to level 5.0 and lower for maximum compatibility. + // Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it. + // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels + // MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880. + if (requestLevel < 0 || requestLevel >= 150) { - // Transcode to level 5.0 and lower for maximum compatibility. - // Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it. - // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels - // MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880. - if (requestLevel < 0 || requestLevel >= 150) - { - return "150"; - } + return "150"; } - else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + } + else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + // Transcode to level 5.1 and lower for maximum compatibility. + // h264 4k 30fps requires at least level 5.1 otherwise it will break on safari fmp4. + // https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels + if (requestLevel < 0 || requestLevel >= 51) { - // Transcode to level 5.1 and lower for maximum compatibility. - // h264 4k 30fps requires at least level 5.1 otherwise it will break on safari fmp4. - // https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels - if (requestLevel < 0 || requestLevel >= 51) - { - return "51"; - } + return "51"; } } @@ -1826,10 +1926,12 @@ namespace MediaBrowser.Controller.MediaEncoding var sub2videoParam = enableSub2video ? ":sub2video=1" : string.Empty; var fontPath = _pathManager.GetAttachmentFolderPath(state.MediaSource.Id); - var fontParam = string.Format( - CultureInfo.InvariantCulture, - ":fontsdir='{0}'", - _mediaEncoder.EscapeSubtitleFilterPath(fontPath)); + var fontParam = fontPath is null + ? string.Empty + : string.Format( + CultureInfo.InvariantCulture, + ":fontsdir='{0}'", + _mediaEncoder.EscapeSubtitleFilterPath(fontPath)); if (state.SubtitleStream.IsExternal) { @@ -1962,11 +2064,15 @@ namespace MediaBrowser.Controller.MediaEncoding args += keyFrameArg + gopArg; } - // global_header produced by AMD HEVC VA-API encoder causes non-playable fMP4 on iOS + // The in-band Parameter Sets generated by the AMD HEVC VA-API encoder is inconsistent + // with the extradata generated by ffmpeg, causing decoding failures when using hvc1. if (string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) && _mediaEncoder.IsVaapiDeviceAmd) { - args += " -flags:v -global_header"; + // Extracting the extradata from the in-band PS to bypass the issue. + // This can be removed once the issue is resolved in libva or Mesa. + // Transcoding is unavoidable here, so using BSF will not conflict with BSF in remuxing. + args += " -flags:v -global_header -bsf:v extract_extradata=remove=0"; } return args; @@ -2189,12 +2295,10 @@ namespace MediaBrowser.Controller.MediaEncoding } } - var level = state.GetRequestedLevel(targetVideoCodec); + var level = NormalizeTranscodingLevel(state, state.GetRequestedLevel(targetVideoCodec)); if (!string.IsNullOrEmpty(level)) { - level = NormalizeTranscodingLevel(state, level); - // libx264, QSV, AMF can adjust the given level to match the output. if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)) @@ -2414,6 +2518,17 @@ namespace MediaBrowser.Controller.MediaEncoding } } + var requestedRotations = state.GetRequestedRotations(videoStream.Codec); + if (requestedRotations.Length > 0) + { + var rotation = state.VideoStream?.Rotation ?? 0; + if (rotation != 0 + && !requestedRotations.Contains(rotation.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) + { + return false; + } + } + // Video width must fall within requested value if (request.MaxWidth.HasValue && (!videoStream.Width.HasValue || videoStream.Width.Value > request.MaxWidth.Value)) @@ -2497,56 +2612,66 @@ namespace MediaBrowser.Controller.MediaEncoding } public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs) + => CanStreamCopyAudio(state, audioStream, supportedAudioCodecs, out _); + + /// <summary> + /// Determines whether the given audio stream can be stream-copied and, regardless of the outcome, + /// reports the codec/parameter incompatibilities that would force a re-encode via <paramref name="failureReasons"/>. + /// </summary> + /// <param name="state">The encoding job state.</param> + /// <param name="audioStream">The source audio stream.</param> + /// <param name="supportedAudioCodecs">The audio codecs the target supports.</param> + /// <param name="failureReasons">The codec/parameter incompatibilities preventing a copy, or <c>0</c> if the stream is copy-compatible.</param> + /// <returns><c>true</c> if the audio stream can be stream-copied; otherwise, <c>false</c>.</returns> + public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs, out TranscodeReason failureReasons) { var request = state.BaseRequest; - if (!request.AllowAudioStreamCopy) - { - return false; - } + // Policy-independent compatibility check, so the reasons are reported even when a policy gate is what ultimately prevents the copy. + failureReasons = GetAudioStreamCopyFailureReasons(state, audioStream, supportedAudioCodecs); + + return request.AllowAudioStreamCopy + && request.EnableAutoStreamCopy + && failureReasons == 0; + } + + private static TranscodeReason GetAudioStreamCopyFailureReasons(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs) + { + var request = state.BaseRequest; + TranscodeReason reasons = 0; var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec); if (maxBitDepth.HasValue && audioStream.BitDepth.HasValue && audioStream.BitDepth.Value > maxBitDepth.Value) { - return false; + reasons |= TranscodeReason.AudioBitDepthNotSupported; } // Source and target codecs must match if (string.IsNullOrEmpty(audioStream.Codec) || !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase)) { - return false; + reasons |= TranscodeReason.AudioCodecNotSupported; } // Channels must fall within requested value var channels = state.GetRequestedAudioChannels(audioStream.Codec); - if (channels.HasValue) + if (channels.HasValue + && (!audioStream.Channels.HasValue + || audioStream.Channels.Value <= 0 + || audioStream.Channels.Value > channels.Value)) { - if (!audioStream.Channels.HasValue || audioStream.Channels.Value <= 0) - { - return false; - } - - if (audioStream.Channels.Value > channels.Value) - { - return false; - } + reasons |= TranscodeReason.AudioChannelsNotSupported; } // Sample rate must fall within requested value - if (request.AudioSampleRate.HasValue) + if (request.AudioSampleRate.HasValue + && (!audioStream.SampleRate.HasValue + || audioStream.SampleRate.Value <= 0 + || audioStream.SampleRate.Value > request.AudioSampleRate.Value)) { - if (!audioStream.SampleRate.HasValue || audioStream.SampleRate.Value <= 0) - { - return false; - } - - if (audioStream.SampleRate.Value > request.AudioSampleRate.Value) - { - return false; - } + reasons |= TranscodeReason.AudioSampleRateNotSupported; } // Audio bitrate must fall within requested value @@ -2554,10 +2679,10 @@ namespace MediaBrowser.Controller.MediaEncoding && audioStream.BitRate.HasValue && audioStream.BitRate.Value > request.AudioBitRate.Value) { - return false; + reasons |= TranscodeReason.AudioBitrateNotSupported; } - return request.EnableAutoStreamCopy; + return reasons; } public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec) @@ -2592,8 +2717,16 @@ namespace MediaBrowser.Controller.MediaEncoding } } - // Cap the max target bitrate to intMax/2 to satisfy the bufsize=bitrate*2. - return Math.Min(bitrate ?? 0, int.MaxValue / 2); + // Cap the max target bitrate to 400 Mbps. + // No consumer or professional hardware transcode target exceeds this value + // (Intel QSV tops out at ~300 Mbps for H.264; HEVC High Tier Level 5.x is ~240 Mbps). + // Without this cap, plugin-provided MPEG-TS streams with no usable bitrate metadata + // can produce unreasonably large -bufsize/-maxrate values for the encoder. + // Note: the existing FallbackMaxStreamingBitrate mechanism (default 30 Mbps) only + // applies when a LiveStreamId is set (M3U/HDHR sources). Plugin streams and other + // sources that bypass the LiveTV pipeline are not covered by it. + const int MaxSaneBitrate = 400_000_000; // 400 Mbps + return Math.Min(bitrate ?? 0, MaxSaneBitrate); } private int GetMinBitrate(int sourceBitrate, int requestedBitrate) @@ -2690,25 +2823,29 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) { +#pragma warning disable SA1008 return (inputChannels, outputChannels) switch { - (>= 6, >= 6 or 0) => Math.Min(640000, bitrate), - (> 0, > 0) => Math.Min(outputChannels * 128000, bitrate), - (> 0, _) => Math.Min(inputChannels * 128000, bitrate), + ( >= 6, >= 6 or 0) => Math.Min(640000, bitrate), + ( > 0, > 0) => Math.Min(outputChannels * 128000, bitrate), + ( > 0, _) => Math.Min(inputChannels * 128000, bitrate), (_, _) => Math.Min(384000, bitrate) }; +#pragma warning restore SA1008 } if (string.Equals(audioCodec, "dts", StringComparison.OrdinalIgnoreCase) || string.Equals(audioCodec, "dca", StringComparison.OrdinalIgnoreCase)) { +#pragma warning disable SA1008 return (inputChannels, outputChannels) switch { - (>= 6, >= 6 or 0) => Math.Min(768000, bitrate), - (> 0, > 0) => Math.Min(outputChannels * 136000, bitrate), - (> 0, _) => Math.Min(inputChannels * 136000, bitrate), + ( >= 6, >= 6 or 0) => Math.Min(768000, bitrate), + ( > 0, > 0) => Math.Min(outputChannels * 136000, bitrate), + ( > 0, _) => Math.Min(inputChannels * 136000, bitrate), (_, _) => Math.Min(672000, bitrate) }; +#pragma warning restore SA1008 } // Empty bitrate area is not allow on iOS @@ -2929,23 +3066,6 @@ namespace MediaBrowser.Controller.MediaEncoding } seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick)); - - if (state.IsVideoRequest) - { - // If we are remuxing, then the copied stream cannot be seeked accurately (it will seek to the nearest - // keyframe). If we are using fMP4, then force all other streams to use the same inaccurate seeking to - // avoid A/V sync issues which cause playback issues on some devices. - // When remuxing video, the segment start times correspond to key frames in the source stream, so this - // option shouldn't change the seeked point that much. - // Important: make sure not to use it with wtv because it breaks seeking - if (state.TranscodingType is TranscodingJobType.Hls - && string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase) - && (IsCopyCodec(state.OutputVideoCodec) || IsCopyCodec(state.OutputAudioCodec)) - && !string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase)) - { - seekParam += " -noaccurate_seek"; - } - } } return seekParam; @@ -3000,11 +3120,8 @@ namespace MediaBrowser.Controller.MediaEncoding int audioStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.AudioStream); if (state.AudioStream.IsExternal) { - bool hasExternalGraphicsSubs = state.SubtitleStream is not null - && ShouldEncodeSubtitle(state) - && state.SubtitleStream.IsExternal - && !state.SubtitleStream.IsTextSubtitleStream; - int externalAudioMapIndex = hasExternalGraphicsSubs ? 2 : 1; + bool hasExternalSubAsInput = NeedsExternalSubtitleMuxing(state); + int externalAudioMapIndex = hasExternalSubAsInput ? 2 : 1; args += string.Format( CultureInfo.InvariantCulture, @@ -3032,12 +3149,31 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (subtitleMethod == SubtitleDeliveryMethod.Embed) { - int subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream); + if (state.SubtitleStream.IsExternal) + { + // External subtitle file is added as second FFmpeg input. + // For single-stream files (SRT/ASS/VTT) the in-file index is always 0. + // For multi-stream containers (MKS) we count how many streams from + // the same file appear before the selected one. + var inFileIndex = state.MediaSource.MediaStreams + .Where(s => string.Equals(s.Path, state.SubtitleStream.Path, StringComparison.Ordinal)) + .TakeWhile(s => s.Index != state.SubtitleStream.Index) + .Count(); - args += string.Format( - CultureInfo.InvariantCulture, - " -map 0:{0}", - subtitleStreamIndex); + args += string.Format( + CultureInfo.InvariantCulture, + " -map 1:{0}", + inFileIndex); + } + else + { + int subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream); + + args += string.Format( + CultureInfo.InvariantCulture, + " -map 0:{0}", + subtitleStreamIndex); + } } else if (state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream) { @@ -3732,9 +3868,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase); - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doToneMap = IsSwTonemapAvailable(state, options); var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI; @@ -3886,9 +4020,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isCuInCuOut = isNvDecoder && isNvencEncoder; var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doCuTonemap = IsHwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); @@ -3922,7 +4054,7 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add(swDeintFilter); } - var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p"; + var outFormat = doCuTonemap ? "p010le" : "yuv420p"; var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); // sw scale mainFilters.Add(swScaleFilter); @@ -4097,9 +4229,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doOclTonemap = IsHwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); @@ -4345,9 +4475,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isQsvInQsvOut = isHwDecoder && isQsvEncoder; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doVppTonemap = IsIntelVppTonemapAvailable(state, options); var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options); var doTonemap = doVppTonemap || doOclTonemap; @@ -4639,12 +4767,10 @@ namespace MediaBrowser.Controller.MediaEncoding var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isQsvInQsvOut = isHwDecoder && isQsvEncoder; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options); var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); var doTonemap = doVaVppTonemap || doOclTonemap; - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; @@ -4970,12 +5096,10 @@ namespace MediaBrowser.Controller.MediaEncoding var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options); var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); var doTonemap = doVaVppTonemap || doOclTonemap; - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; @@ -5207,10 +5331,8 @@ namespace MediaBrowser.Controller.MediaEncoding var isSwEncoder = !isVaapiEncoder; var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doVkTonemap = IsVulkanHwTonemapAvailable(state, options); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; @@ -5447,9 +5569,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965; var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doOclTonemap = IsHwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); @@ -5680,9 +5800,7 @@ namespace MediaBrowser.Controller.MediaEncoding var reqMaxH = state.BaseRequest.MaxHeight; var threeDFormat = state.MediaSource.Video3DFormat; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options); var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options); var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface); @@ -5881,9 +5999,7 @@ namespace MediaBrowser.Controller.MediaEncoding && (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase) || vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase)); - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doOclTonemap = IsHwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); @@ -6147,12 +6263,21 @@ namespace MediaBrowser.Controller.MediaEncoding overlayFilters?.RemoveAll(string.IsNullOrEmpty); var framerate = GetFramerateParam(state); - if (framerate.HasValue) + if (mainFilters is not null && framerate.HasValue) { - mainFilters.Insert(0, string.Format( - CultureInfo.InvariantCulture, - "fps={0}", - framerate.Value)); + var doDeintH2645 = IsDeinterlaceAvailable(state); + var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value); + + // For filter chain containing the deinterlace filter, + // place the fps filter at the end to preserve temporal info. + if (doDeintH2645) + { + mainFilters.Add(fpsFilter); + } + else + { + mainFilters.Insert(0, fpsFilter); + } } var mainStr = string.Empty; @@ -6359,17 +6484,15 @@ namespace MediaBrowser.Controller.MediaEncoding } // Block unsupported H.264 Hi422P and Hi444PP profiles, which can be encoded with 4:2:0 pixel format - if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase) + && ((videoStream.Profile?.Contains("4:2:2", StringComparison.OrdinalIgnoreCase) ?? false) + || (videoStream.Profile?.Contains("4:4:4", StringComparison.OrdinalIgnoreCase) ?? false))) { - if (videoStream.Profile.Contains("4:2:2", StringComparison.OrdinalIgnoreCase) - || videoStream.Profile.Contains("4:4:4", StringComparison.OrdinalIgnoreCase)) + // VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P + if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox + && RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64))) { - // VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P - if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox - && RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64))) - { - return null; - } + return null; } } @@ -7105,8 +7228,9 @@ namespace MediaBrowser.Controller.MediaEncoding && !IsCopyCodec(state.OutputVideoCodec) && options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio; + TranscodeReason audioCopyFailureReasons = 0; if (state.AudioStream is not null - && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs) + && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs, out audioCopyFailureReasons) && !preventHlsAudioCopy) { state.OutputAudioCodec = "copy"; @@ -7120,12 +7244,18 @@ namespace MediaBrowser.Controller.MediaEncoding { state.OutputAudioCodec = "copy"; } + else if (state.AudioStream is not null && !IsCopyCodec(state.OutputAudioCodec)) + { + // Audio is actually being re-encoded although the playback determination may have considered the source copyable. + // Only carry the primary "cannot be passed through" cause - the codec mismatch. + // Bitrate/channels/sample-rate/bit-depth copy refusals are consequences of the chosen transcode target. + state.AddTranscodeReason(audioCopyFailureReasons & TranscodeReason.AudioCodecNotSupported); + } } } - public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer) + private string GetFfmpegAnalyzeDurationArg(EncodingJobInfo state) { - var inputModifier = string.Empty; var analyzeDurationArgument = string.Empty; // Apply -analyzeduration as per the environment variable, @@ -7141,6 +7271,26 @@ namespace MediaBrowser.Controller.MediaEncoding analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration; } + return analyzeDurationArgument; + } + + private string GetFfmpegProbesizeArg() + { + var ffmpegProbeSize = _config.GetFFmpegProbeSize(); + + if (!string.IsNullOrEmpty(ffmpegProbeSize)) + { + return $"-probesize {ffmpegProbeSize}"; + } + + return string.Empty; + } + + public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer) + { + var inputModifier = string.Empty; + var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state); + if (!string.IsNullOrEmpty(analyzeDurationArgument)) { inputModifier += " " + analyzeDurationArgument; @@ -7149,11 +7299,11 @@ namespace MediaBrowser.Controller.MediaEncoding inputModifier = inputModifier.Trim(); // Apply -probesize if configured - var ffmpegProbeSize = _config.GetFFmpegProbeSize(); + var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg(); - if (!string.IsNullOrEmpty(ffmpegProbeSize)) + if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument)) { - inputModifier += $" -probesize {ffmpegProbeSize}"; + inputModifier += " " + ffmpegProbeSizeArgument; } var userAgentParam = GetUserAgentParam(state); @@ -7193,8 +7343,10 @@ namespace MediaBrowser.Controller.MediaEncoding inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion); } + int readrate = 0; if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp) { + readrate = 1; inputModifier += " -re"; } else if (encodingOptions.EnableSegmentDeletion @@ -7205,7 +7357,15 @@ namespace MediaBrowser.Controller.MediaEncoding { // Set an input read rate limit 10x for using SegmentDeletion with stream-copy // to prevent ffmpeg from exiting prematurely (due to fast drive) - inputModifier += " -readrate 10"; + readrate = 10; + inputModifier += $" -readrate {readrate}"; + } + + // Set a larger catchup value to revert to the old behavior, + // otherwise, remuxing might stall due to this new option + if (readrate > 0 && _mediaEncoder.EncoderVersion >= _minFFmpegReadrateCatchupOption) + { + inputModifier += $" -readrate_catchup {readrate * 100}"; } var flags = new List<string>(); @@ -7710,13 +7870,14 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); } - if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) + var sampleRate = state.OutputAudioSampleRate; + if (sampleRate.HasValue) { - // opus only supports specific sampling rates - var sampleRate = state.OutputAudioSampleRate; - if (sampleRate.HasValue) + var sampleRateValue = sampleRate.Value; + if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) { - var sampleRateValue = sampleRate.Value switch + // opus only supports specific sampling rates + sampleRateValue = sampleRate.Value switch { <= 8000 => 8000, <= 12000 => 12000, @@ -7724,9 +7885,9 @@ namespace MediaBrowser.Controller.MediaEncoding <= 24000 => 24000, _ => 48000 }; - - audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } + + audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } // Copy the movflags from GetProgressiveVideoFullCommandLine @@ -7787,6 +7948,14 @@ namespace MediaBrowser.Controller.MediaEncoding || (state.BaseRequest.AlwaysBurnInSubtitleWhenTranscoding && !IsCopyCodec(state.OutputVideoCodec)); } + private static bool NeedsExternalSubtitleMuxing(EncodingJobInfo state) + { + return state.SubtitleStream is not null + && state.SubtitleStream.IsExternal + && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed + || (ShouldEncodeSubtitle(state) && !state.SubtitleStream.IsTextSubtitleStream)); + } + public static string GetVideoSyncOption(string videoSync, Version encoderVersion) { if (string.IsNullOrEmpty(videoSync)) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 7d0384ef27..314cd32903 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -515,6 +515,15 @@ namespace MediaBrowser.Controller.MediaEncoding public int HlsListSize => 0; + /// <summary> + /// Adds the specified reason(s) to <see cref="TranscodeReasons"/>. + /// </summary> + /// <param name="reason">The transcode reason(s) to add.</param> + public void AddTranscodeReason(TranscodeReason reason) + { + _transcodeReasons = TranscodeReasons | reason; + } + private int? GetMediaStreamCount(MediaStreamType type, int limit) { var count = MediaSource.GetStreamCount(type); @@ -571,62 +580,50 @@ namespace MediaBrowser.Controller.MediaEncoding public string[] GetRequestedProfiles(string codec) { - if (!string.IsNullOrEmpty(BaseRequest.Profile)) - { - return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + var profile = BaseRequest.Profile; - if (!string.IsNullOrEmpty(codec)) + if (string.IsNullOrEmpty(profile) && !string.IsNullOrEmpty(codec)) { - var profile = BaseRequest.GetOption(codec, "profile"); - - if (!string.IsNullOrEmpty(profile)) - { - return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + profile = BaseRequest.GetOption(codec, "profile"); } - return Array.Empty<string>(); + return (profile ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries); } public string[] GetRequestedRangeTypes(string codec) { - if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType)) - { - return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + var rangetype = BaseRequest.VideoRangeType; - if (!string.IsNullOrEmpty(codec)) + if (string.IsNullOrEmpty(rangetype) && !string.IsNullOrEmpty(codec)) { - var rangetype = BaseRequest.GetOption(codec, "rangetype"); - - if (!string.IsNullOrEmpty(rangetype)) - { - return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + rangetype = BaseRequest.GetOption(codec, "rangetype"); } - return Array.Empty<string>(); + return (rangetype ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries); } public string[] GetRequestedCodecTags(string codec) { - if (!string.IsNullOrEmpty(BaseRequest.CodecTag)) + var codectag = BaseRequest.CodecTag; + + if (string.IsNullOrEmpty(codectag) && !string.IsNullOrEmpty(codec)) { - return BaseRequest.CodecTag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); + codectag = BaseRequest.GetOption(codec, "codectag"); } - if (!string.IsNullOrEmpty(codec)) - { - var codectag = BaseRequest.GetOption(codec, "codectag"); + return (codectag ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries); + } - if (!string.IsNullOrEmpty(codectag)) - { - return codectag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + public string[] GetRequestedRotations(string codec) + { + var rotation = BaseRequest.Rotation; + + if (string.IsNullOrEmpty(rotation) && !string.IsNullOrEmpty(codec)) + { + rotation = BaseRequest.GetOption(codec, "rotation"); } - return Array.Empty<string>(); + return (rotation ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries); } public string GetRequestedLevel(string codec) diff --git a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs index 5a6d15d781..9bee653e2e 100644 --- a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs +++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -31,4 +32,13 @@ public interface IMediaSegmentProvider /// <param name="item">The base item to extract segments from.</param> /// <returns>True if item is supported, otherwise false.</returns> ValueTask<bool> Supports(BaseItem item); + + /// <summary> + /// Called when extracted segment data for an item is being pruned. + /// Providers should delete any cached analysis data they hold for the given item. + /// </summary> + /// <param name="itemId">The item whose data is being pruned.</param> + /// <param name="cancellationToken">Abort token.</param> + /// <returns>A task representing the asynchronous cleanup operation.</returns> + Task CleanupExtractedData(Guid itemId, CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 1e0d77fe51..2bcce168cf 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -40,11 +40,6 @@ namespace MediaBrowser.Controller.Net /// </summary> private readonly List<(IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)> _activeConnections = new(); - /// <summary> - /// The logger. - /// </summary> - protected readonly ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; - private readonly Task _messageConsumerTask; protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) @@ -57,6 +52,11 @@ namespace MediaBrowser.Controller.Net } /// <summary> + /// Gets the Logger. + /// </summary> + protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger { get; } + + /// <summary> /// Gets the type used for the messages sent to the client. /// </summary> /// <value>The type.</value> @@ -209,6 +209,11 @@ namespace MediaBrowser.Controller.Net var (connection, cts, state) = tuple; var cancellationToken = cts.Token; + // Restore the culture context captured when the connection was established + // so that GetDataToSendForConnection produces a localized payload matching + // the client's Accept-Language preference rather than the server default. + connection.ApplyRequestCulture(); + var data = await GetDataToSendForConnection(connection).ConfigureAwait(false); if (data is null) { diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs index bdc0f9a10f..48431e75c3 100644 --- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs +++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs @@ -77,5 +77,14 @@ namespace MediaBrowser.Controller.Net /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> Task ReceiveAsync(CancellationToken cancellationToken = default); + + /// <summary> + /// Applies the culture context captured when the connection was established + /// (from the upgrade request's <c>Accept-Language</c> header) to the current + /// async flow. Server-initiated message senders should call this before + /// localising any payload so that the response uses the client's preferred + /// language rather than the server default. + /// </summary> + void ApplyRequestCulture(); } } diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs new file mode 100644 index 0000000000..d57f1fc893 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides item counting and played-status query operations. +/// </summary> +public interface IItemCountService +{ + /// <summary> + /// Gets the count of items matching the filter. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The item count.</returns> + int GetCount(InternalItemsQuery filter); + + /// <summary> + /// Gets item counts grouped by type. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The item counts by type.</returns> + ItemCounts GetItemCounts(InternalItemsQuery filter); + + /// <summary> + /// Gets item counts for a "by-name" item using an optimized query. + /// </summary> + /// <param name="kind">The kind of the name item.</param> + /// <param name="id">The ID of the name item.</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 grouped by type.</returns> + ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, 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> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>The count of played descendant items.</returns> + int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets the total count of items that are descendants of the specified ancestor. + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>The total count of descendant items.</returns> + int GetTotalCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets both the played count and total count of descendant items. + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>A tuple containing (Played count, Total count).</returns> + (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets both the played count and total count from linked children. + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="parentId">The parent item id.</param> + /// <returns>A tuple containing (Played count, Total count).</returns> + (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId); + + /// <summary> + /// Batch-fetches played and total counts for multiple folder items. + /// </summary> + /// <param name="folderIds">The list of folder item IDs to get counts for.</param> + /// <param name="user">The user for access filtering and played status.</param> + /// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns> + Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user); + + /// <summary> + /// Batch-fetches child counts for multiple parent folders. + /// </summary> + /// <param name="parentIds">The list of parent folder IDs.</param> + /// <param name="userId">The user ID for access filtering.</param> + /// <returns>Dictionary mapping parent ID to child count.</returns> + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); +} diff --git a/MediaBrowser.Controller/Persistence/IItemPersistenceService.cs b/MediaBrowser.Controller/Persistence/IItemPersistenceService.cs new file mode 100644 index 0000000000..37f7194e7a --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IItemPersistenceService.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides item persistence operations (save, delete, update). +/// </summary> +public interface IItemPersistenceService +{ + /// <summary> + /// Deletes items by their IDs. + /// </summary> + /// <param name="ids">The IDs to delete.</param> + void DeleteItem(params IReadOnlyList<Guid> ids); + + /// <summary> + /// Saves items to the database. + /// </summary> + /// <param name="items">The items to save.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken); + + /// <summary> + /// Saves image info for an item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the asynchronous operation.</returns> + Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default); + + /// <summary> + /// Reattaches user data entries to the correct item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the asynchronous operation.</returns> + Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken); + + /// <summary> + /// Updates inherited values. + /// </summary> + void UpdateInheritedValues(); +} diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs new file mode 100644 index 0000000000..2e29cbdbba --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides shared query-building methods used by extracted item services. +/// Implemented by <c>BaseItemRepository</c>. +/// </summary> +public interface IItemQueryHelpers +{ + /// <summary> + /// Translates an <see cref="InternalItemsQuery"/> into EF Core filter expressions. + /// </summary> + /// <param name="baseQuery">The base queryable to filter.</param> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The filtered queryable.</returns> + IQueryable<BaseItemEntity> TranslateQuery( + IQueryable<BaseItemEntity> baseQuery, + JellyfinDbContext context, + InternalItemsQuery filter); + + /// <summary> + /// Prepares a base query for items from the context. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The prepared queryable.</returns> + IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter); + + /// <summary> + /// Applies user access filtering (library access, parental controls, tags) to a query. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The base queryable to filter.</param> + /// <param name="filter">The query filter containing access settings.</param> + /// <returns>The access-filtered queryable.</returns> + IQueryable<BaseItemEntity> ApplyAccessFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter); + + /// <summary> + /// Applies navigation property includes to a query based on filter options. + /// </summary> + /// <param name="dbQuery">The queryable to apply navigations to.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The queryable with navigation includes.</returns> + IQueryable<BaseItemEntity> ApplyNavigations( + IQueryable<BaseItemEntity> dbQuery, + InternalItemsQuery filter); + + /// <summary> + /// Applies ordering to a query based on filter options. + /// </summary> + /// <param name="query">The queryable to order.</param> + /// <param name="filter">The query filter.</param> + /// <param name="context">The database context.</param> + /// <returns>The ordered queryable.</returns> + IQueryable<BaseItemEntity> ApplyOrder( + IQueryable<BaseItemEntity> query, + InternalItemsQuery filter, + JellyfinDbContext context); + + /// <summary> + /// Builds a query for descendants of an ancestor with user access filtering applied. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <param name="ancestorId">The ancestor item ID.</param> + /// <returns>The filtered descendant queryable.</returns> + IQueryable<BaseItemEntity> BuildAccessFilteredDescendantsQuery( + JellyfinDbContext context, + InternalItemsQuery filter, + Guid ancestorId); + + /// <summary> + /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played + /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// </summary> + /// <param name="context">The database context the resulting query is bound to.</param> + /// <param name="folderIds">A query yielding candidate folder IDs.</param> + /// <param name="user">The user for access filtering and played status.</param> + /// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns> + IQueryable<Guid> GetFullyPlayedFolderIdsQuery( + JellyfinDbContext context, + IQueryable<Guid> folderIds, + User user); + + /// <summary> + /// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>. + /// </summary> + /// <param name="entity">The database entity.</param> + /// <param name="skipDeserialization">Whether to skip JSON deserialization.</param> + /// <returns>The deserialized item, or null.</returns> + BaseItem? DeserializeBaseItem(BaseItemEntity entity, bool skipDeserialization = false); + + /// <summary> + /// Prepares a filter query by adjusting limits and virtual item settings. + /// </summary> + /// <param name="query">The query to prepare.</param> + void PrepareFilterQuery(InternalItemsQuery query); +} diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index bf80b7d0a8..291916ab25 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -1,15 +1,11 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; @@ -21,29 +17,6 @@ namespace MediaBrowser.Controller.Persistence; public interface IItemRepository { /// <summary> - /// Deletes the item. - /// </summary> - /// <param name="ids">The identifier to delete.</param> - void DeleteItem(params IReadOnlyList<Guid> ids); - - /// <summary> - /// Saves the items. - /// </summary> - /// <param name="items">The items.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken); - - Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default); - - /// <summary> - /// Reattaches the user data to the item. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A task that represents the asynchronous reattachment operation.</returns> - Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken); - - /// <summary> /// Retrieves the item. /// </summary> /// <param name="id">The id.</param> @@ -80,62 +53,91 @@ public interface IItemRepository IReadOnlyList<BaseItem> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType); /// <summary> - /// Gets the list of series presentation keys for next up. + /// Checks if an item has been persisted to the database. /// </summary> - /// <param name="filter">The query.</param> - /// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param> - /// <returns>The list of keys.</returns> - IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff); + /// <param name="id">The id to check.</param> + /// <returns>True if the item exists, otherwise false.</returns> + Task<bool> ItemExistsAsync(Guid id); /// <summary> - /// Updates the inherited values. + /// Gets genres with item counts. /// </summary> - void UpdateInheritedValues(); - - int GetCount(InternalItemsQuery filter); - - ItemCounts GetItemCounts(InternalItemsQuery filter); - + /// <param name="filter">The query filter.</param> + /// <returns>The genres and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter); + /// <summary> + /// Gets music genres with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The music genres and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter); + /// <summary> + /// Gets studios with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The studios and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter); + /// <summary> + /// Gets artists with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The artists and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter); + /// <summary> + /// Gets album artists with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The album artists and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter); + /// <summary> + /// Gets all artists with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>All artists and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter); + /// <summary> + /// Gets all music genre names. + /// </summary> + /// <returns>The list of music genre names.</returns> IReadOnlyList<string> GetMusicGenreNames(); + /// <summary> + /// Gets all studio names. + /// </summary> + /// <returns>The list of studio names.</returns> IReadOnlyList<string> GetStudioNames(); + /// <summary> + /// Gets all genre names. + /// </summary> + /// <returns>The list of genre names.</returns> IReadOnlyList<string> GetGenreNames(); - IReadOnlyList<string> GetAllArtistNames(); - /// <summary> - /// Checks if an item has been persisted to the database. + /// Gets all artist names. /// </summary> - /// <param name="id">The id to check.</param> - /// <returns>True if the item exists, otherwise false.</returns> - Task<bool> ItemExistsAsync(Guid id); + /// <returns>The list of artist names.</returns> + IReadOnlyList<string> GetAllArtistNames(); /// <summary> - /// Gets a value indicating wherever all children of the requested Id has been played. + /// Gets legacy query filters aggregated from the database. /// </summary> - /// <param name="user">The userdata to check against.</param> - /// <param name="id">The Top id to check.</param> - /// <param name="recursive">Whever the check should be done recursive. Warning expensive operation.</param> - /// <returns>A value indicating whever all children has been played.</returns> - bool GetIsPlayed(User user, Guid id, bool recursive); + /// <param name="filter">The query filter.</param> + /// <returns>Aggregated filter values.</returns> + QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter); /// <summary> - /// Gets all artist matches from the db. + /// Gets whether all children of the requested item have been played. /// </summary> - /// <param name="artistNames">The names of the artists.</param> - /// <returns>A map of the artist name and the potential matches.</returns> - IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames); + /// <param name="user">The user to check against.</param> + /// <param name="id">The top item id to check.</param> + /// <param name="recursive">Whether the check should be done recursively.</param> + /// <returns>A value indicating whether all children have been played.</returns> + bool GetIsPlayed(User user, Guid id, bool recursive); } diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs new file mode 100644 index 0000000000..a4614fc125 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities.Audio; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides linked children query and manipulation operations. +/// </summary> +public interface ILinkedChildrenService +{ + /// <summary> + /// Gets the IDs of linked children for the specified parent. + /// </summary> + /// <param name="parentId">The parent item ID.</param> + /// <param name="childType">Optional child type filter.</param> + /// <returns>List of child item IDs.</returns> + IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null); + + /// <summary> + /// Gets all artist matches from the database. + /// </summary> + /// <param name="artistNames">The names of the artists.</param> + /// <returns>A map of the artist name and the potential matches.</returns> + IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames); + + /// <summary> + /// Gets parent IDs that reference the specified child with LinkedChildType.Manual. + /// </summary> + /// <param name="childId">The child item ID.</param> + /// <param name="parentType">Optional parent item type filter.</param> + /// <returns>List of parent IDs that reference the child.</returns> + IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null); + + /// <summary> + /// Updates LinkedChildren references from one child to another. + /// </summary> + /// <param name="fromChildId">The child ID to re-route from.</param> + /// <param name="toChildId">The child ID to re-route to.</param> + /// <returns>List of parent item IDs whose LinkedChildren were modified.</returns> + IReadOnlyList<Guid> RerouteLinkedChildren(Guid fromChildId, Guid toChildId); + + /// <summary> + /// Creates or updates a LinkedChild entry. + /// </summary> + /// <param name="parentId">The parent item ID.</param> + /// <param name="childId">The child item ID.</param> + /// <param name="childType">The type of linked child relationship.</param> + void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType); +} diff --git a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs index 665129eafd..de04ff021d 100644 --- a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs +++ b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs @@ -22,6 +22,13 @@ public interface IMediaStreamRepository IReadOnlyList<MediaStream> GetMediaStreams(MediaStreamQuery filter); /// <summary> + /// Gets all language codes of the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The type of the media stream.</param> + /// <returns>IEnumerable{string}.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); + + /// <summary> /// Saves the media streams. /// </summary> /// <param name="id">The identifier.</param> diff --git a/MediaBrowser.Controller/Persistence/INextUpService.cs b/MediaBrowser.Controller/Persistence/INextUpService.cs new file mode 100644 index 0000000000..ade026d0da --- /dev/null +++ b/MediaBrowser.Controller/Persistence/INextUpService.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides next-up episode query operations. +/// </summary> +public interface INextUpService +{ + /// <summary> + /// Gets the list of series presentation keys for next up. + /// </summary> + /// <param name="filter">The query.</param> + /// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param> + /// <returns>The list of keys.</returns> + IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff); + + /// <summary> + /// Gets next up episodes for multiple series in a single batched query. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <param name="seriesKeys">The series presentation unique keys to query.</param> + /// <param name="includeSpecials">Whether to include specials.</param> + /// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param> + /// <returns>A dictionary mapping series key to batch result.</returns> + IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery filter, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching); +} diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index 418289cb4c..e2833dc722 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -1,13 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Persistence; +/// <summary> +/// Provides methods for accessing Peoples. +/// </summary> public interface IPeopleRepository { /// <summary> @@ -15,7 +17,7 @@ public interface IPeopleRepository /// </summary> /// <param name="filter">The query.</param> /// <returns>The list of people matching the filter.</returns> - IReadOnlyList<PersonInfo> GetPeople(InternalPeopleQuery filter); + QueryResult<PersonInfo> GetPeople(InternalPeopleQuery filter); /// <summary> /// Updates the people. @@ -30,4 +32,12 @@ public interface IPeopleRepository /// <param name="filter">The query.</param> /// <returns>The list of people names matching the filter.</returns> IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter); + + /// <summary> + /// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table. + /// </summary> + /// <param name="itemIds">The item IDs to get people for.</param> + /// <param name="personTypes">The person types to include (e.g. "Actor", "Director").</param> + /// <returns>A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted.</returns> + IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes); } diff --git a/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs b/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs new file mode 100644 index 0000000000..f5b09498b9 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Result of a batched NextUp query for a single series. +/// </summary> +public sealed class NextUpEpisodeBatchResult +{ + /// <summary> + /// Gets or sets the last watched episode (highest season/episode that is played). + /// </summary> + public BaseItem? LastWatched { get; set; } + + /// <summary> + /// Gets or sets the next unwatched episode after the last watched position. + /// </summary> + public BaseItem? NextUp { get; set; } + + /// <summary> + /// Gets or sets specials that may air between episodes. + /// Only populated when includeSpecials is true. + /// </summary> + public IReadOnlyList<BaseItem>? Specials { get; set; } + + /// <summary> + /// Gets or sets the last watched episode for rewatching mode (most recently played). + /// Only populated when includeWatchedForRewatching is true. + /// </summary> + public BaseItem? LastWatchedForRewatching { get; set; } + + /// <summary> + /// Gets or sets the next played episode for rewatching mode. + /// Only populated when includeWatchedForRewatching is true. + /// </summary> + public BaseItem? NextPlayedForRewatching { get; set; } +} diff --git a/MediaBrowser.Controller/Plugins/IHasEmbeddedImage.cs b/MediaBrowser.Controller/Plugins/IHasEmbeddedImage.cs new file mode 100644 index 0000000000..4196cd9f24 --- /dev/null +++ b/MediaBrowser.Controller/Plugins/IHasEmbeddedImage.cs @@ -0,0 +1,17 @@ +namespace MediaBrowser.Controller.Plugins; + +/// <summary> +/// Marker interface for integrated/bundled plugins that ship their plugin image as an embedded +/// resource inside the plugin assembly rather than as a file on disk. +/// </summary> +/// <remarks> +/// This interface is intended for plugins compiled into the server. External plugins should +/// continue to declare their image via the <c>imagePath</c> field in <c>meta.json</c>. +/// </remarks> +public interface IHasEmbeddedImage +{ + /// <summary> + /// Gets the name of the embedded resource in this plugin's assembly to serve as the plugin image. + /// </summary> + string ImageResourceName { get; } +} diff --git a/MediaBrowser.Controller/Providers/BookInfo.cs b/MediaBrowser.Controller/Providers/BookInfo.cs index 3055c5d871..7f8151e534 100644 --- a/MediaBrowser.Controller/Providers/BookInfo.cs +++ b/MediaBrowser.Controller/Providers/BookInfo.cs @@ -1,11 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for books. + /// </summary> public class BookInfo : ItemLookupInfo { + /// <summary> + /// Gets or sets the name of the series the book belongs to. + /// </summary> public string SeriesName { get; set; } } } diff --git a/MediaBrowser.Controller/Providers/BoxSetInfo.cs b/MediaBrowser.Controller/Providers/BoxSetInfo.cs index f43ea67178..22dbdb959e 100644 --- a/MediaBrowser.Controller/Providers/BoxSetInfo.cs +++ b/MediaBrowser.Controller/Providers/BoxSetInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for box sets. + /// </summary> public class BoxSetInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index a1edfa3c96..6060d051a5 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using MediaBrowser.Model.IO; @@ -26,7 +27,20 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata[] GetFileSystemEntries(string path) { - return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem); + return _cache.GetOrAdd( + path, + static (p, fileSystem) => + { + try + { + return fileSystem.GetFileSystemEntries(p).ToArray(); + } + catch (DirectoryNotFoundException) + { + return []; + } + }, + _fileSystem); } public List<FileSystemMetadata> GetDirectories(string path) @@ -91,19 +105,27 @@ namespace MediaBrowser.Controller.Providers public IReadOnlyList<string> GetFilePaths(string path) => GetFilePaths(path, false); - public IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false) + public IReadOnlyList<string> GetFilePaths(string path, bool clearCache) { if (clearCache) { _filePathCache.TryRemove(path, out _); } - var filePaths = _filePathCache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFilePaths(p).ToList(), _fileSystem); - - if (sort) - { - filePaths.Sort(); - } + var filePaths = _filePathCache.GetOrAdd( + path, + static (p, fileSystem) => + { + try + { + return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); + } + catch (DirectoryNotFoundException) + { + return []; + } + }, + _fileSystem); return filePaths; } diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index 1babf73af8..8a3fa33da3 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -21,7 +21,7 @@ namespace MediaBrowser.Controller.Providers IReadOnlyList<string> GetFilePaths(string path); - IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false); + IReadOnlyList<string> GetFilePaths(string path, bool clearCache); bool IsAccessible(string path); } diff --git a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs index 42cb523713..834e173ca3 100644 --- a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs +++ b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs @@ -1,10 +1,16 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// Interface for items that provide lookup info for metadata providers. + /// </summary> + /// <typeparam name="TLookupInfoType">The type of the lookup info.</typeparam> public interface IHasLookupInfo<out TLookupInfoType> where TLookupInfoType : ItemLookupInfo, new() { + /// <summary> + /// Gets the lookup info. + /// </summary> + /// <returns>The lookup info.</returns> TLookupInfoType GetLookupInfo(); } } diff --git a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs index 6d98af33e4..668160759f 100644 --- a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs +++ b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// Marker interface for custom metadata providers that run before the regular metadata refresh. + /// </summary> public interface IPreRefreshProvider : ICustomMetadataProvider { } diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 0d3a334dfb..c87f09a117 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -144,6 +144,17 @@ namespace MediaBrowser.Controller.Providers where T : BaseItem; /// <summary> + /// Gets the metadata providers for the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="includeDisabled">Whether to include disabled providers.</param> + /// <typeparam name="T">The type of metadata provider.</typeparam> + /// <returns>The metadata providers.</returns> + IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled) + where T : BaseItem; + + /// <summary> /// Gets the metadata savers for the provided item. /// </summary> /// <param name="item">The item.</param> diff --git a/MediaBrowser.Controller/Providers/MovieInfo.cs b/MediaBrowser.Controller/Providers/MovieInfo.cs index 20e6b697ad..a33f8bbfe2 100644 --- a/MediaBrowser.Controller/Providers/MovieInfo.cs +++ b/MediaBrowser.Controller/Providers/MovieInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for movies. + /// </summary> public class MovieInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs index 11cb71f902..d0eb5cb825 100644 --- a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs +++ b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for persons. + /// </summary> public class PersonLookupInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/SeriesInfo.cs b/MediaBrowser.Controller/Providers/SeriesInfo.cs index 976fa175ad..5ca5f0a534 100644 --- a/MediaBrowser.Controller/Providers/SeriesInfo.cs +++ b/MediaBrowser.Controller/Providers/SeriesInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for series. + /// </summary> public class SeriesInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/TrailerInfo.cs b/MediaBrowser.Controller/Providers/TrailerInfo.cs index 630850f9db..c30468db6c 100644 --- a/MediaBrowser.Controller/Providers/TrailerInfo.cs +++ b/MediaBrowser.Controller/Providers/TrailerInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for trailers. + /// </summary> public class TrailerInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index 96783f6073..fb68bfb770 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -45,7 +45,6 @@ namespace MediaBrowser.Controller.Session PlayState = new PlayerStateInfo(); SessionControllers = []; NowPlayingQueue = []; - NowPlayingQueueFullItems = []; } /// <summary> @@ -272,15 +271,9 @@ namespace MediaBrowser.Controller.Session public IReadOnlyList<QueueItem> NowPlayingQueue { get; set; } /// <summary> - /// Gets or sets the now playing queue full items. - /// </summary> - /// <value>The now playing queue full items.</value> - public IReadOnlyList<BaseItemDto> NowPlayingQueueFullItems { get; set; } - - /// <summary> /// Gets or sets a value indicating whether the session has a custom device name. /// </summary> - /// <value><c>true</c> if this session has a custom device name; otherwise, <c>false</c>.</value> + /// <value><c>true</c> if the session has a custom device name; otherwise, <c>false</c>.</value> public bool HasCustomDeviceName { get; set; } /// <summary> diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs index 132765b719..eb38eeb503 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -141,7 +141,8 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString()); // Ignore request and return to previous state. - IGroupState newState = prevState switch { + IGroupState newState = prevState switch + { GroupStateType.Playing => new PlayingGroupState(LoggerFactory), GroupStateType.Paused => new PausedGroupState(LoggerFactory), _ => new IdleGroupState(LoggerFactory) |
