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