diff options
Diffstat (limited to 'MediaBrowser.Controller')
67 files changed, 1740 insertions, 360 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/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/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 822b21c062..49a4ed4bf6 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -23,7 +23,6 @@ using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; @@ -88,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() @@ -216,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> @@ -1091,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()) { @@ -1102,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() @@ -1120,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 { @@ -1141,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, @@ -1220,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>(); @@ -1228,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()); @@ -1290,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); @@ -1561,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() @@ -1595,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) @@ -2002,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> @@ -2709,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) @@ -2719,18 +2851,28 @@ 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 LibraryManager.GetItemList(new InternalItemsQuery() + return LibraryManager.GetItemList(new InternalItemsQuery(user) { - OwnerIds = [Id], + OwnerIds = GetExtraOwnerIds(), OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] }); } @@ -2739,12 +2881,13 @@ namespace MediaBrowser.Controller.Entities /// 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 LibraryManager.GetItemList(new InternalItemsQuery() + return LibraryManager.GetItemList(new InternalItemsQuery(user) { - OwnerIds = [Id], + OwnerIds = GetExtraOwnerIds(), ExtraTypes = extraTypes.ToArray(), OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] }); 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 5fa1213db3..b1f7f29bad 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -384,6 +384,7 @@ namespace MediaBrowser.Controller.Entities cancellationToken.ThrowIfCancellationRequested(); var validChildren = new List<BaseItem>(); + var accessibleChildren = new List<BaseItem>(); var validChildrenNeedGeneration = false; if (IsFileProtocol) @@ -438,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) { @@ -480,11 +488,12 @@ namespace MediaBrowser.Controller.Entities child.SetParent(this); newItems.Add(child); validChildren.Add(child); + accessibleChildren.Add(child); } // That's all the new and changed ones - now see if any have been removed and need cleanup var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); - var shouldRemove = !IsRoot || allowRemoveRoot; + // If it's an AggregateFolder, don't remove // Collect replaced primaries for deferred deletion (after CreateItems) var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>(); @@ -497,7 +506,7 @@ namespace MediaBrowser.Controller.Entities .Where(p => !string.IsNullOrEmpty(p)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - if (shouldRemove && itemsRemoved.Count > 0) + if (itemsRemoved.Count > 0) { foreach (var item in itemsRemoved) { @@ -703,7 +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) @@ -742,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); } } } @@ -906,7 +915,10 @@ namespace MediaBrowser.Controller.Entities query.Parent = this; } - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet) + // 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 QueryWithPostFiltering(query); } @@ -927,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)); } diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index fa82ea8663..3b1f6a961f 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -21,6 +21,7 @@ namespace MediaBrowser.Controller.Entities AlbumArtistIds = []; AlbumIds = []; AncestorIds = []; + LinkedChildAncestorIds = []; ArtistIds = []; BlockUnratedItems = []; BoxSetLibraryFolders = []; @@ -58,6 +59,8 @@ namespace MediaBrowser.Controller.Entities VideoTypes = []; Years = []; SkipDeserialization = false; + AudioLanguages = []; + SubtitleLanguages = []; } public InternalItemsQuery(User? user) @@ -69,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; } @@ -263,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; } @@ -351,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; } @@ -385,6 +492,10 @@ namespace MediaBrowser.Controller.Entities 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; diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index dbe6f94dfd..42e4f79942 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -153,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 f70f7dfb4c..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) 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 cb05056601..c57ed2faf8 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities case CollectionType.folders: return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query); + case CollectionType.books: + return GetBooks(queryParent, user, query); + case CollectionType.tvshows: return GetTvView(queryParent, user, query); @@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities return _libraryManager.GetItemsResult(query); } + private QueryResult<BaseItem> GetBooks(Folder parent, User user, InternalItemsQuery query) + { + query.Recursive = true; + query.Parent = parent; + query.SetUser(user); + + query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook }; + + return _libraryManager.GetItemsResult(query); + } + private QueryResult<BaseItem> GetMovieMovies(Folder parent, User user, InternalItemsQuery query) { query.Recursive = true; diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 80bcd62dcd..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; @@ -33,11 +34,11 @@ namespace MediaBrowser.Controller.Entities { public Video() { - AdditionalParts = Array.Empty<string>(); - LocalAlternateVersions = Array.Empty<string>(); - SubtitleFiles = Array.Empty<string>(); - AudioFiles = Array.Empty<string>(); - LinkedAlternateVersions = Array.Empty<LinkedChild>(); + AdditionalParts = []; + LocalAlternateVersions = []; + SubtitleFiles = []; + AudioFiles = []; + LinkedAlternateVersions = []; } [JsonIgnore] @@ -253,7 +254,7 @@ namespace MediaBrowser.Controller.Entities private int GetMediaSourceCount(HashSet<Guid> callstack = null) { - callstack ??= new(); + callstack ??= []; if (PrimaryVersionId.HasValue) { var item = LibraryManager.GetItemById(PrimaryVersionId.Value); @@ -278,6 +279,17 @@ namespace MediaBrowser.Controller.Entities 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() { var list = base.GetUserDataKeys(); @@ -323,6 +335,102 @@ namespace MediaBrowser.Controller.Entities PresentationUniqueKey = CreatePresentationUniqueKey(); } + /// <summary> + /// Marks the played status of this video and propagates it to its alternate versions. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="datePlayed">The date played.</param> + /// <param name="resetPosition">if set to <c>true</c> [reset position].</param> + public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition) + { + base.MarkPlayed(user, datePlayed, resetPosition); + PropagatePlayedState(user, true, resetPosition); + } + + /// <summary> + /// Marks this video unplayed and propagates the change to its alternate versions. + /// </summary> + /// <param name="user">The user.</param> + public override void MarkUnplayed(User user) + { + base.MarkUnplayed(user); + + // MarkUnplayed always clears the position on this video, so reset the versions too. + PropagatePlayedState(user, false, true); + } + + /// <summary> + /// Propagates the played status to every alternate version of this video. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="played">The played status to apply to the alternate versions.</param> + /// <param name="resetPosition">When marking played, controls whether each version's resume point + /// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed, + /// which always fully resets every version.</param> + public void PropagatePlayedState(User user, bool played, bool resetPosition = true) + { + ArgumentNullException.ThrowIfNull(user); + + if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions) + { + return; + } + + foreach (var (item, _) in GetAllItemsForMediaSources()) + { + if (item.Id.Equals(Id) || item is not Video) + { + continue; + } + + if (played) + { + var dto = new UpdateUserItemDataDto { Played = true }; + if (resetPosition) + { + dto.PlaybackPositionTicks = 0; + } + + // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved. + UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed); + } + else + { + var data = UserDataManager.GetUserData(user, item); + if (data is null) + { + continue; + } + + ResetPlayedState(data); + UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); + } + } + } + + /// <summary> + /// Gets this video together with all of its alternate versions (local and linked and, when this + /// is itself an alternate, the primary and the primary's other versions), deduplicated. + /// </summary> + /// <returns>This video and every alternate version of it.</returns> + public IReadOnlyList<Video> GetAllVersions() + { + return GetAllItemsForMediaSources() + .Select(i => i.Item) + .OfType<Video>() + .ToList(); + } + + /// <summary> + /// Gets the alternate version of this video that matches the supplied item id. + /// </summary> + /// <param name="itemId">The version item id (the playback media source id).</param> + /// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns> + public Video GetAlternateVersion(Guid itemId) + { + return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId)); + } + public override string CreatePresentationUniqueKey() { if (PrimaryVersionId.HasValue) @@ -379,13 +487,13 @@ namespace MediaBrowser.Controller.Entities /// <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); } @@ -630,39 +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( - LibraryManager.GetLinkedAlternateVersions(this) - .Select(i => ((BaseItem)i, MediaSourceType.Grouping))); + return GetAllItemsForMediaSources() + .Select(i => i.Item.Id) + .Distinct() + .ToArray(); + } - if (PrimaryVersionId.HasValue) - { - if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary) - { - var existingIds = list.Select(i => i.Item1.Id).ToList(); - list.Add((primary, MediaSourceType.Grouping)); - list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); - } - } + 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 ? LibraryManager.GetLocalAlternateVersionIds(video) : 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(); - - list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default))); + .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 f5e3d7034e..0b64da291c 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -598,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> @@ -784,5 +792,12 @@ namespace MediaBrowser.Controller.Library /// <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 798812bf1f..2ee8845346 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -63,6 +63,24 @@ namespace MediaBrowser.Controller.Library 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> @@ -80,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/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 a0e04eae63..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; @@ -86,6 +87,7 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0); private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1); private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0); + private readonly Version _minFFmpegNoiseBsfDrop = new Version(5, 0); private static readonly string[] _videoProfilesH264 = [ @@ -443,6 +445,13 @@ namespace MediaBrowser.Controller.MediaEncoding || 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) { var videoStream = state.VideoStream; @@ -1267,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"); @@ -1307,7 +1313,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekSubParam); } - if (!string.IsNullOrEmpty(canvasArgs)) + if (isGraphicalBurnIn && !string.IsNullOrEmpty(canvasArgs)) { arg.Append(canvasArgs); } @@ -1550,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) @@ -1645,10 +1692,9 @@ namespace MediaBrowser.Controller.MediaEncoding } 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}"); } @@ -1767,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) @@ -1783,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) @@ -1807,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" }; } @@ -1880,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) { @@ -2016,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; @@ -2466,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)) @@ -2549,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 @@ -2606,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) @@ -2750,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 @@ -2989,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; @@ -3060,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, @@ -3092,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) { @@ -3792,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; @@ -3946,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); @@ -3982,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); @@ -4157,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); @@ -4405,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; @@ -4699,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; @@ -5030,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; @@ -5267,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; @@ -5507,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); @@ -5740,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); @@ -5941,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); @@ -6207,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; @@ -7163,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"; @@ -7178,6 +7244,13 @@ 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); + } } } @@ -7797,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, @@ -7811,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 @@ -7874,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 54da218530..9bee653e2e 100644 --- a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs +++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; 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/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs index d0cddf54a6..a4614fc125 100644 --- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.Audio; using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; @@ -29,8 +30,9 @@ public interface ILinkedChildrenService /// 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); + IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null); /// <summary> /// Updates LinkedChildren references from one child to another. 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/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index a89f3ef9ee..e2833dc722 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -32,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/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 5b5af75a47..6060d051a5 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -105,7 +105,7 @@ 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) { @@ -118,7 +118,7 @@ namespace MediaBrowser.Controller.Providers { try { - return fileSystem.GetFilePaths(p).ToList(); + return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); } catch (DirectoryNotFoundException) { @@ -127,11 +127,6 @@ namespace MediaBrowser.Controller.Providers }, _fileSystem); - if (sort) - { - filePaths.Sort(); - } - 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) |
