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/ClientEvent/ClientEventLogger.cs10
-rw-r--r--MediaBrowser.Controller/Drawing/ImageHelper.cs4
-rw-r--r--MediaBrowser.Controller/Dto/DtoOptions.cs7
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs317
-rw-r--r--MediaBrowser.Controller/Entities/Book.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs55
-rw-r--r--MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs5
-rw-r--r--MediaBrowser.Controller/Entities/IHasStartDate.cs8
-rw-r--r--MediaBrowser.Controller/Entities/IItemByName.cs15
-rw-r--r--MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs5
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs103
-rw-r--r--MediaBrowser.Controller/Entities/InternalPeopleQuery.cs14
-rw-r--r--MediaBrowser.Controller/Entities/Movies/Movie.cs2
-rw-r--r--MediaBrowser.Controller/Entities/MusicVideo.cs2
-rw-r--r--MediaBrowser.Controller/Entities/PeopleHelper.cs74
-rw-r--r--MediaBrowser.Controller/Entities/Person.cs11
-rw-r--r--MediaBrowser.Controller/Entities/SourceType.cs16
-rw-r--r--MediaBrowser.Controller/Entities/TV/Episode.cs2
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs45
-rw-r--r--MediaBrowser.Controller/Entities/Trailer.cs2
-rw-r--r--MediaBrowser.Controller/Entities/UserRootFolder.cs8
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs62
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs266
-rw-r--r--MediaBrowser.Controller/IO/IExternalDataManager.cs7
-rw-r--r--MediaBrowser.Controller/Library/IExternalSearchProvider.cs20
-rw-r--r--MediaBrowser.Controller/Library/IInternalSearchProvider.cs8
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs23
-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/IUserDataManager.cs26
-rw-r--r--MediaBrowser.Controller/Library/SearchProviderQuery.cs51
-rw-r--r--MediaBrowser.Controller/Library/SearchResult.cs60
-rw-r--r--MediaBrowser.Controller/Library/VersionPlaybackSelector.cs59
-rw-r--r--MediaBrowser.Controller/Library/VersionResumeData.cs41
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs176
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs9
-rw-r--r--MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs21
-rw-r--r--MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs26
-rw-r--r--MediaBrowser.Controller/Persistence/IItemRepository.cs9
-rw-r--r--MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs9
-rw-r--r--MediaBrowser.Controller/Persistence/IPeopleRepository.cs7
-rw-r--r--MediaBrowser.Controller/Providers/BookInfo.cs8
-rw-r--r--MediaBrowser.Controller/Providers/BoxSetInfo.cs5
-rw-r--r--MediaBrowser.Controller/Providers/IHasLookupInfo.cs10
-rw-r--r--MediaBrowser.Controller/Providers/IPreRefreshProvider.cs5
-rw-r--r--MediaBrowser.Controller/Providers/MetadataResult.cs8
-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.cs18
-rw-r--r--MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs25
61 files changed, 1570 insertions, 321 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/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
index 14dc64dabd..36f0d2195c 100644
--- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
+++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
+using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent
{
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
/// <inheritdoc />
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{
- var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
+ var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
+ var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
+ var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
+ if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
+ {
+ throw new ArgumentException("Path resolved to filename not in log directory");
+ }
+
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false))
{
diff --git a/MediaBrowser.Controller/Drawing/ImageHelper.cs b/MediaBrowser.Controller/Drawing/ImageHelper.cs
index 9ef92bc981..6f26b7d912 100644
--- a/MediaBrowser.Controller/Drawing/ImageHelper.cs
+++ b/MediaBrowser.Controller/Drawing/ImageHelper.cs
@@ -11,7 +11,9 @@ namespace MediaBrowser.Controller.Drawing
// Determine the output size based on incoming parameters
var newSize = DrawingUtils.Resize(originalImageSize, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0);
newSize = DrawingUtils.ResizeFill(newSize, options.FillWidth, options.FillHeight);
- return newSize;
+
+ // Never encode larger than the source.
+ return DrawingUtils.ScaleDownToFit(newSize, originalImageSize);
}
}
}
diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs
index d319feb6b2..052626355f 100644
--- a/MediaBrowser.Controller/Dto/DtoOptions.cs
+++ b/MediaBrowser.Controller/Dto/DtoOptions.cs
@@ -82,13 +82,6 @@ namespace MediaBrowser.Controller.Dto
public bool AddCurrentProgram { get; set; }
/// <summary>
- /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
- /// image, falling back to the series') should replace the episode's own (16:9) primary image.
- /// Used by views that render episodes as poster cards, e.g. "Latest".
- /// </summary>
- public bool PreferEpisodeParentPoster { get; set; }
-
- /// <summary>
/// Gets a value indicating whether the specified field is populated.
/// </summary>
/// <param name="field">The field to check.</param>
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 21304768bd..28f40cb7fa 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -87,6 +88,8 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
+ private protected static readonly char[] VersionDelimiters = ['-', '_', '.'];
+
private string _sortName;
private string _forcedSortName;
@@ -538,8 +541,8 @@ namespace MediaBrowser.Controller.Entities
{
if (!string.IsNullOrEmpty(ForcedSortName))
{
- // Need the ToLower because that's what CreateSortName does
- _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant();
+ // Run the forced sort name through the same cleaning as auto-generated sort names.
+ _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
else
{
@@ -768,6 +771,17 @@ namespace MediaBrowser.Controller.Entities
[JsonIgnore]
protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol;
+ /// <summary>
+ /// Gets a value indicating whether this item searches the folder it lives in for its own extras.
+ /// </summary>
+ [JsonIgnore]
+ protected virtual bool SearchesContainingFolderForExtras =>
+ IsFileProtocol
+ && SupportsOwnedItems
+ && !IsInMixedFolder
+ && this is not (ICollectionFolder or UserRootFolder or AggregateFolder)
+ && GetType() != typeof(Folder);
+
[JsonIgnore]
public virtual bool SupportsPeople => false;
@@ -924,19 +938,31 @@ namespace MediaBrowser.Controller.Entities
/// <returns>System.String.</returns>
protected virtual string CreateSortName()
{
- if (Name is null)
+ return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
+ }
+
+ /// <summary>
+ /// Cleans a raw name into its sortable form by applying the configured sort rules.
+ /// </summary>
+ /// <param name="name">The raw name to clean.</param>
+ /// <param name="enableAlphaNumericSorting">Whether alphanumeric sorting rules should be applied.</param>
+ /// <param name="configuration">The server configuration providing the sort rules.</param>
+ /// <returns>The cleaned, sortable name, or <c>null</c> if <paramref name="name"/> is <c>null</c>.</returns>
+ public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration)
+ {
+ if (name is null)
{
return null; // some items may not have name filled in properly
}
- if (!EnableAlphaNumericSorting)
+ if (!enableAlphaNumericSorting)
{
- return Name.TrimStart();
+ return name.TrimStart();
}
- var sortable = Name.Trim().ToLowerInvariant();
+ var sortable = name.Trim().ToLowerInvariant();
- foreach (var search in ConfigurationManager.Configuration.SortRemoveWords)
+ foreach (var search in configuration.SortRemoveWords)
{
// Remove from beginning if a space follows
if (sortable.StartsWith(search + " ", StringComparison.Ordinal))
@@ -954,12 +980,12 @@ namespace MediaBrowser.Controller.Entities
}
}
- foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters)
+ foreach (var removeChar in configuration.SortRemoveCharacters)
{
sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
}
- foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters)
+ foreach (var replaceChar in configuration.SortReplaceCharacters)
{
sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
}
@@ -1099,8 +1125,9 @@ namespace MediaBrowser.Controller.Entities
}
}
- var list = GetAllItemsForMediaSources();
- var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList();
+ var list = GetAllItemsForMediaSources().ToList();
+ var commonPrefix = GetCommonNamePrefix(list);
+ var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList();
if (IsActiveRecording())
{
@@ -1110,17 +1137,15 @@ namespace MediaBrowser.Controller.Entities
}
}
- return result.OrderBy(i =>
- {
- if (i.VideoType == VideoType.VideoFile)
- {
- return 0;
- }
+ // The source belonging to the item being queried sorts first so it is the default the client plays.
+ var selfId = Id.ToString("N", CultureInfo.InvariantCulture);
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i => i, new MediaSourceWidthComparator())
- .ToArray();
+ return result
+ .OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1)
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i => i, new MediaSourceWidthComparator())
+ .ToArray();
}
protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
@@ -1128,7 +1153,7 @@ namespace MediaBrowser.Controller.Entities
return Enumerable.Empty<(BaseItem, MediaSourceType)>();
}
- private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type)
+ private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null)
{
ArgumentNullException.ThrowIfNull(item);
@@ -1141,7 +1166,7 @@ namespace MediaBrowser.Controller.Entities
Protocol = protocol ?? MediaProtocol.File,
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
- Name = GetMediaSourceName(item),
+ Name = GetMediaSourceName(item, commonPrefix),
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
@@ -1220,7 +1245,7 @@ namespace MediaBrowser.Controller.Entities
return info;
}
- internal string GetMediaSourceName(BaseItem item)
+ internal string GetMediaSourceName(BaseItem item, string commonPrefix = null)
{
var terms = new List<string>();
@@ -1228,12 +1253,31 @@ namespace MediaBrowser.Controller.Entities
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
{
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
- if (HasLocalAlternateVersions)
+
+ // Prefer the suffix that differs from the other versions: strip the prefix shared by
+ // all sibling files. This works regardless of folder layout, so it also labels episode
+ // versions that share a season folder (e.g. "Greyscale" instead of the full
+ // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter
+ // boundary (see GetCommonVersionPrefix).
+ if (!string.IsNullOrEmpty(commonPrefix)
+ && displayName.Length > commonPrefix.Length
+ && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]);
+ if (!name.IsWhiteSpace())
+ {
+ terms.Add(name.ToString());
+ }
+ }
+
+ // Fall back to the containing folder name (the common layout for movie versions, and
+ // the path taken when no common prefix could be derived).
+ if (terms.Count == 0 && HasLocalAlternateVersions)
{
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
{
- var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
+ var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1290,6 +1334,98 @@ namespace MediaBrowser.Controller.Entities
return string.Join('/', terms);
}
+ /// <summary>
+ /// Derives the prefix shared by the supplied media source items' file names, used to strip the
+ /// common part and surface a short version label per source. Returns null when there are fewer
+ /// than two file-based sources, since there is nothing to differentiate.
+ /// </summary>
+ /// <param name="items">The media source items.</param>
+ /// <returns>The shared prefix, or null when no useful prefix exists.</returns>
+ private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items)
+ {
+ var fileNames = new List<string>();
+ foreach (var (item, _) in items)
+ {
+ if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path))
+ {
+ fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path));
+ }
+ }
+
+ if (fileNames.Count < 2)
+ {
+ return null;
+ }
+
+ var prefix = GetCommonVersionPrefix(fileNames);
+ return string.IsNullOrEmpty(prefix) ? null : prefix;
+ }
+
+ /// <summary>
+ /// Computes the case-insensitive longest common prefix of the supplied version file names,
+ /// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact:
+ /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
+ /// "Greyish") while still trimming the common part when every version is suffixed (e.g.
+ /// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a
+ /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is
+ /// kept in the label, falling back to a space only when no structural delimiter is shared. The
+ /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming
+ /// VideoFlagDelimiters).
+ /// </summary>
+ /// <param name="fileNames">The version file names without extension; must contain at least one entry.</param>
+ /// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns>
+ internal static string GetCommonVersionPrefix(IReadOnlyList<string> fileNames)
+ {
+ var prefix = fileNames[0];
+ for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++)
+ {
+ var name = fileNames[i];
+ var length = Math.Min(prefix.Length, name.Length);
+ var common = 0;
+ while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common]))
+ {
+ common++;
+ }
+
+ prefix = prefix[..common];
+ }
+
+ // If the common prefix is itself a whole file name then one version is unlabelled (the
+ // base name); the boundary already sits at the end of that name, so don't retreat into it.
+ var prefixIsWholeName = false;
+ for (var i = 0; i < fileNames.Count; i++)
+ {
+ if (fileNames[i].Length == prefix.Length)
+ {
+ prefixIsWholeName = true;
+ break;
+ }
+ }
+
+ if (!prefixIsWholeName)
+ {
+ // Retreat to the last structural delimiter ('-', '_', '.').
+ var cut = prefix.Length;
+ while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0)
+ {
+ cut--;
+ }
+
+ if (cut == 0)
+ {
+ cut = prefix.Length;
+ while (cut > 0 && prefix[cut - 1] != ' ')
+ {
+ cut--;
+ }
+ }
+
+ prefix = prefix[..cut];
+ }
+
+ return prefix;
+ }
+
public Task RefreshMetadata(CancellationToken cancellationToken)
{
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
@@ -1403,7 +1539,14 @@ namespace MediaBrowser.Controller.Entities
/// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns>
protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
- if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder))
+ if (!SearchesContainingFolderForExtras)
+ {
+ return false;
+ }
+
+ if (GetParent() is Folder container
+ && container.SearchesContainingFolderForExtras
+ && string.Equals(container.Path, ContainingFolderPath, StringComparison.OrdinalIgnoreCase))
{
return false;
}
@@ -1418,33 +1561,59 @@ namespace MediaBrowser.Controller.Entities
private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
+ // An extra is owned by the version it is named after, so all of them are maintained together.
+ var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery()
+ {
+ OwnerIds = item.GetOwnedVersionIds()
+ }).Where(e => e.ExtraType.HasValue).ToList();
+
+ var currentExtraIds = currentExtras.Select(e => e.Id).ToArray();
+
+ // Snapshot the persisted names before resolving, as FindExtras corrects the name on the
+ // items it hands back and may well hand back these very instances.
+ var currentExtraNames = new Dictionary<Guid, string>();
+ foreach (var extra in currentExtras)
+ {
+ currentExtraNames[extra.Id] = extra.Name;
+ }
+
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
- var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery()
- {
- OwnerIds = [item.Id]
- }).Select(e => e.Id).ToArray();
+ var renamedExtraIds = extras
+ .Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal))
+ .Select(e => e.Id)
+ .ToHashSet();
var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x));
- if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
+ if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
{
+ // The owner's dates may only have become known after its extras were created, so keep
+ // them in sync even when there is nothing to refresh.
+ foreach (var extra in currentExtras)
+ {
+ if (extra.ExtraType is not null && InheritDatesFromOwner(item, extra))
+ {
+ await extra.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
return false;
}
- var ownerId = item.Id;
-
var tasks = extras.Select(i =>
{
+ var ownerId = item.GetOwnerIdForExtra(i);
var subOptions = new MetadataRefreshOptions(options);
- if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty())
+ if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id))
{
subOptions.ForceSave = true;
}
i.OwnerId = ownerId;
i.ParentId = Guid.Empty;
+
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
});
@@ -2011,12 +2180,23 @@ namespace MediaBrowser.Controller.Entities
// I think it is okay to do this here.
// if this is only called when a user is manually forcing something to un-played
// then it probably is what we want to do...
+ ResetPlayedState(data);
+
+ UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Clears the played state on the supplied user data.
+ /// </summary>
+ /// <param name="data">The user data to reset.</param>
+ protected static void ResetPlayedState(UserItemData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
data.PlayCount = 0;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = null;
data.Played = false;
-
- UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
}
/// <summary>
@@ -2516,6 +2696,32 @@ namespace MediaBrowser.Controller.Entities
}
}
+ /// <summary>
+ /// Applies the owner's premiere date and production year to an owned item, returning whether anything changed.
+ /// </summary>
+ /// <param name="owner">The owner.</param>
+ /// <param name="ownedItem">The owned item.</param>
+ /// <returns><c>true</c> if the owned item was changed, else <c>false</c>.</returns>
+ internal static bool InheritDatesFromOwner(BaseItem owner, BaseItem ownedItem)
+ {
+ // Extras have no release date of their own, so the owner's is authoritative.
+ var changed = false;
+
+ if (owner.ProductionYear is not null && ownedItem.ProductionYear != owner.ProductionYear)
+ {
+ ownedItem.ProductionYear = owner.ProductionYear;
+ changed = true;
+ }
+
+ if (owner.PremiereDate is not null && ownedItem.PremiereDate != owner.PremiereDate)
+ {
+ ownedItem.PremiereDate = owner.PremiereDate;
+ changed = true;
+ }
+
+ return changed;
+ }
+
protected async Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
var newOptions = new MetadataRefreshOptions(options)
@@ -2575,6 +2781,11 @@ namespace MediaBrowser.Controller.Entities
ownedItem.CustomRating = item.CustomRating;
newOptions.ForceSave = true;
}
+
+ if (InheritDatesFromOwner(item, ownedItem))
+ {
+ newOptions.ForceSave = true;
+ }
}
await ownedItem.RefreshMetadata(newOptions, cancellationToken).ConfigureAwait(false);
@@ -2732,6 +2943,34 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
+ /// Gets the ids of the items whose owned extras belong to this item.
+ /// </summary>
+ /// <returns>An array containing the owner ids.</returns>
+ protected virtual Guid[] GetExtraOwnerIds()
+ {
+ return [Id];
+ }
+
+ /// <summary>
+ /// Gets the ids of this item and the versions of it whose extras it maintains.
+ /// </summary>
+ /// <returns>An array containing the version ids.</returns>
+ protected virtual Guid[] GetOwnedVersionIds()
+ {
+ return [Id];
+ }
+
+ /// <summary>
+ /// Gets the id of the version an extra belongs to.
+ /// </summary>
+ /// <param name="extra">The extra.</param>
+ /// <returns>The id of the owning version.</returns>
+ protected virtual Guid GetOwnerIdForExtra(BaseItem extra)
+ {
+ return Id;
+ }
+
+ /// <summary>
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
/// </summary>
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
@@ -2740,7 +2979,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
@@ -2755,7 +2994,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
ExtraTypes = extraTypes.ToArray(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs
index 5187669373..8559681bdc 100644
--- a/MediaBrowser.Controller/Entities/Book.cs
+++ b/MediaBrowser.Controller/Entities/Book.cs
@@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities
[Common.RequiresSourceSerialisation]
public class Book : BaseItem, IHasLookupInfo<BookInfo>, IHasSeries
{
- public Book()
- {
- this.RunTimeTicks = TimeSpan.TicksPerSecond;
- }
-
[JsonIgnore]
public override MediaType MediaType => MediaType.Book;
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 25cbcedc5f..d8203ea6f2 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities
public class Folder : BaseItem
{
private IEnumerable<BaseItem> _children;
-
- public Folder()
- {
- LinkedChildren = Array.Empty<LinkedChild>();
- }
+ private LinkedChild[] _linkedChildren = [];
public static IUserViewManager UserViewManager { get; set; }
@@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities
/// Gets or sets the linked children.
/// </summary>
[JsonIgnore]
- public LinkedChild[] LinkedChildren { get; set; }
+ public LinkedChild[] LinkedChildren
+ {
+ get => _linkedChildren;
+ set
+ {
+ _linkedChildren = value;
+
+ // Assigning the collection means the caller knows the complete set of links.
+ LinkedChildrenLoaded = true;
+ }
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links.
+ /// </summary>
+ /// <remarks>
+ /// An unloaded instance carries an empty array that means "unknown", not "no children" —
+ /// persisting it would delete every link the item has.
+ /// </remarks>
+ [JsonIgnore]
+ public bool LinkedChildrenLoaded { get; private set; }
[JsonIgnore]
public DateTime? DateLastMediaAdded { get; set; }
@@ -384,6 +400,7 @@ namespace MediaBrowser.Controller.Entities
cancellationToken.ThrowIfCancellationRequested();
var validChildren = new List<BaseItem>();
+ var accessibleChildren = new List<BaseItem>();
var validChildrenNeedGeneration = false;
if (IsFileProtocol)
@@ -438,12 +455,19 @@ namespace MediaBrowser.Controller.Entities
{
if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot))
{
+ // Preserve inaccessible items so they aren't treated as removed.
+ if (currentChildren.TryGetValue(child.Id, out var childrenToKeep))
+ {
+ validChildren.Add(childrenToKeep);
+ }
+
continue;
}
if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild))
{
validChildren.Add(currentChild);
+ accessibleChildren.Add(currentChild);
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
{
@@ -480,11 +504,12 @@ namespace MediaBrowser.Controller.Entities
child.SetParent(this);
newItems.Add(child);
validChildren.Add(child);
+ accessibleChildren.Add(child);
}
// That's all the new and changed ones - now see if any have been removed and need cleanup
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
- var shouldRemove = !IsRoot || allowRemoveRoot;
+
// If it's an AggregateFolder, don't remove
// Collect replaced primaries for deferred deletion (after CreateItems)
var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
@@ -497,7 +522,7 @@ namespace MediaBrowser.Controller.Entities
.Where(p => !string.IsNullOrEmpty(p))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- if (shouldRemove && itemsRemoved.Count > 0)
+ if (itemsRemoved.Count > 0)
{
foreach (var item in itemsRemoved)
{
@@ -703,7 +728,7 @@ namespace MediaBrowser.Controller.Entities
validChildrenNeedGeneration = false;
}
- await ValidateSubFolders(validChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
+ await ValidateSubFolders(accessibleChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
}
if (refreshChildMetadata)
@@ -742,7 +767,7 @@ namespace MediaBrowser.Controller.Entities
validChildren = Children.ToList();
}
- await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
+ await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -1076,15 +1101,7 @@ namespace MediaBrowser.Controller.Entities
items = ApplyNameFilter(items, query);
}
- var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
- var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
-
- if (query.EnableTotalRecordCount)
- {
- result.TotalRecordCount = filteredItems.Count;
- }
-
- return result;
+ return UserViewBuilder.SortAndPage(items, null, query, LibraryManager);
}
private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query)
diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
index f47d2162f7..0cdc8bce03 100644
--- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
+++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
@@ -1,12 +1,13 @@
#nullable disable
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that have special features.
+ /// </summary>
public interface IHasSpecialFeatures
{
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs
index dab15eb018..47df09d1ce 100644
--- a/MediaBrowser.Controller/Entities/IHasStartDate.cs
+++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs
@@ -1,11 +1,15 @@
-#pragma warning disable CS1591
-
using System;
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that have a start date.
+ /// </summary>
public interface IHasStartDate
{
+ /// <summary>
+ /// Gets or sets the start date.
+ /// </summary>
DateTime StartDate { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs
index 4928bda7a2..756dbecb98 100644
--- a/MediaBrowser.Controller/Entities/IItemByName.cs
+++ b/MediaBrowser.Controller/Entities/IItemByName.cs
@@ -1,19 +1,28 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
/// <summary>
- /// Marker interface.
+ /// Marker interface for items that represent a name, like a genre or a studio.
/// </summary>
public interface IItemByName
{
+ /// <summary>
+ /// Gets the items tagged with this name.
+ /// </summary>
+ /// <param name="query">The query.</param>
+ /// <returns>The tagged items.</returns>
IReadOnlyList<BaseItem> GetTaggedItems(InternalItemsQuery query);
}
+ /// <summary>
+ /// Interface for by-name items that can also be accessed as a regular library item.
+ /// </summary>
public interface IHasDualAccess : IItemByName
{
+ /// <summary>
+ /// Gets a value indicating whether the item is accessed by name.
+ /// </summary>
bool IsAccessedByName { get; }
}
}
diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
index cdda8ea399..0f8904df5c 100644
--- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
+++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that can be placeholders.
+ /// </summary>
public interface ISupportsPlaceHolders
{
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 422c40ce5d..e85f86b72f 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -72,6 +72,102 @@ namespace MediaBrowser.Controller.Entities
}
}
+ /// <summary>
+ /// Gets a value indicating whether the query carries any criteria that narrows the
+ /// result set, as opposed to user context, pagination, sorting or DTO options.
+ /// </summary>
+ public bool HasFilters =>
+ IncludeItemTypes.Length > 0
+ || ExcludeItemTypes.Length > 0
+ || Genres.Count > 0
+ || GenreIds.Count > 0
+ || Years.Length > 0
+ || Tags.Length > 0
+ || ExcludeTags.Length > 0
+ || OfficialRatings.Length > 0
+ || StudioIds.Length > 0
+ || ArtistIds.Length > 0
+ || AlbumArtistIds.Length > 0
+ || ContributingArtistIds.Length > 0
+ || ExcludeArtistIds.Length > 0
+ || AlbumIds.Length > 0
+ || PersonIds.Length > 0
+ || PersonTypes.Length > 0
+ || MediaTypes.Length > 0
+ || VideoTypes.Length > 0
+ || ImageTypes.Length > 0
+ || SeriesStatuses.Length > 0
+ || ItemIds.Length > 0
+ || ExcludeItemIds.Length > 0
+ || AudioLanguages.Count > 0
+ || SubtitleLanguages.Count > 0
+ || LinkedChildAncestorIds.Length > 0
+ || AncestorIds.Length > 0
+ || IsFavorite.HasValue
+ || IsFavoriteOrLiked.HasValue
+ || IsLiked.HasValue
+ || IsPlayed.HasValue
+ || IsResumable.HasValue
+ || IsFolder.HasValue
+ || IsMissing.HasValue
+ || IsUnaired.HasValue
+ || IsSpecialSeason.HasValue
+ || Is3D.HasValue
+ || IsHD.HasValue
+ || Is4K.HasValue
+ || IsLocked.HasValue
+ || IsPlaceHolder.HasValue
+ || IsMovie.HasValue
+ || IsSports.HasValue
+ || IsKids.HasValue
+ || IsNews.HasValue
+ || IsSeries.HasValue
+ || IsAiring.HasValue
+ || IsVirtualItem.HasValue
+ || HasImdbId.HasValue
+ || HasTmdbId.HasValue
+ || HasTvdbId.HasValue
+ || HasOverview.HasValue
+ || HasOfficialRating.HasValue
+ || HasParentalRating.HasValue
+ || HasThemeSong.HasValue
+ || HasThemeVideo.HasValue
+ || HasSubtitles.HasValue
+ || HasSpecialFeature.HasValue
+ || HasTrailer.HasValue
+ || HasChapterImages.HasValue
+ || MinCriticRating.HasValue
+ || MinCommunityRating.HasValue
+ || MinParentalRating is not null
+ || MinIndexNumber.HasValue
+ || MinParentAndIndexNumber.HasValue
+ || IndexNumber.HasValue
+ || ParentIndexNumber.HasValue
+ || AiredDuringSeason.HasValue
+ || MinWidth.HasValue
+ || MinHeight.HasValue
+ || MaxWidth.HasValue
+ || MaxHeight.HasValue
+ || MinPremiereDate.HasValue
+ || MaxPremiereDate.HasValue
+ || MinStartDate.HasValue
+ || MaxStartDate.HasValue
+ || MinEndDate.HasValue
+ || MaxEndDate.HasValue
+ || MinDateCreated.HasValue
+ || MinDateLastSaved.HasValue
+ || MinDateLastSavedForUser.HasValue
+ || AdjacentTo.HasValue
+ || !string.IsNullOrEmpty(NameStartsWith)
+ || !string.IsNullOrEmpty(NameStartsWithOrGreater)
+ || !string.IsNullOrEmpty(NameLessThan)
+ || !string.IsNullOrEmpty(NameContains)
+ || !string.IsNullOrEmpty(MinSortName)
+ || !string.IsNullOrEmpty(Name)
+ || !string.IsNullOrEmpty(Person)
+ || !string.IsNullOrEmpty(SearchTerm)
+ || !string.IsNullOrEmpty(Path);
+
public bool Recursive { get; set; }
public int? StartIndex { get; set; }
@@ -400,6 +496,12 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<string> SubtitleLanguages { get; set; }
+ /// <summary>
+ /// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>.
+ /// Filters that only exist to hide content can be skipped entirely when this is false.
+ /// </summary>
+ public bool UserHasContentRestrictions { get; private set; }
+
public void SetUser(User user)
{
var maxRating = user.MaxParentalRatingScore;
@@ -423,6 +525,7 @@ namespace MediaBrowser.Controller.Entities
.Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
.ToArray();
+ UserHasContentRestrictions = user.HasContentRestrictions();
User = user;
}
diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs
index e12ba22343..8d2a959f4d 100644
--- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs
@@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities
{
PersonTypes = personTypes;
ExcludePersonTypes = excludePersonTypes;
+ EnableTotalRecordCount = true;
}
+ /// <summary>
+ /// Gets or sets a value indicating whether to count the matching people. Under an
+ /// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops
+ /// at the limit, the count has to check every person.
+ /// </summary>
+ public bool EnableTotalRecordCount { get; set; }
+
public int? StartIndex { get; set; }
/// <summary>
@@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities
public User User { get; set; }
public bool? IsFavorite { get; set; }
+
+ /// <summary>
+ /// Gets or sets the item query whose access settings (library access, parental rating, tags)
+ /// people must satisfy through at least one of the items they are credited on.
+ /// </summary>
+ public InternalItemsQuery AccessFilter { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs
index e8817a29cf..8c3ce2ff58 100644
--- a/MediaBrowser.Controller/Entities/Movies/Movie.cs
+++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs
@@ -90,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
- if (!ProductionYear.HasValue)
+ if (ProductionYear is null)
{
var info = LibraryManager.ParseName(Name);
diff --git a/MediaBrowser.Controller/Entities/MusicVideo.cs b/MediaBrowser.Controller/Entities/MusicVideo.cs
index 237ad5198c..effbf98820 100644
--- a/MediaBrowser.Controller/Entities/MusicVideo.cs
+++ b/MediaBrowser.Controller/Entities/MusicVideo.cs
@@ -40,7 +40,7 @@ namespace MediaBrowser.Controller.Entities
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
- if (!ProductionYear.HasValue)
+ if (ProductionYear is null)
{
var info = LibraryManager.ParseName(Name);
diff --git a/MediaBrowser.Controller/Entities/PeopleHelper.cs b/MediaBrowser.Controller/Entities/PeopleHelper.cs
index 24b1843ce6..29f238d8ea 100644
--- a/MediaBrowser.Controller/Entities/PeopleHelper.cs
+++ b/MediaBrowser.Controller/Entities/PeopleHelper.cs
@@ -35,57 +35,61 @@ namespace MediaBrowser.Controller.Entities
person.Type = PersonKind.Writer;
}
- // If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes
- if (person.Type == PersonKind.GuestStar)
- {
- var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor);
+ // Check for dupes based on the combination of Name, Type and Role.
+ var existing = people.FirstOrDefault(p => IsSameCredit(p, person)
+ && string.Equals(p.Role ?? string.Empty, person.Role ?? string.Empty, StringComparison.OrdinalIgnoreCase));
- if (existing is not null)
- {
- existing.Type = PersonKind.GuestStar;
- MergeExisting(existing, person);
- return;
- }
- }
-
- if (person.Type == PersonKind.Actor)
+ if (existing is null)
{
- // If the actor already exists without a role and we have one, fill it in
- var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar));
- if (existing is null)
+ if (string.IsNullOrEmpty(person.Role))
{
- // Wasn't there - add it
- people.Add(person);
+ existing = people.FirstOrDefault(p => IsSameCredit(p, person));
}
else
{
- // Was there, if no role and we have one - fill it in
- if (string.IsNullOrEmpty(existing.Role) && !string.IsNullOrEmpty(person.Role))
+ // If the person already exists without a role and we have one, fill it in
+ existing = people.FirstOrDefault(p => IsSameCredit(p, person) && string.IsNullOrEmpty(p.Role));
+ if (existing is not null)
{
existing.Role = person.Role;
}
-
- MergeExisting(existing, person);
}
}
- else
+
+ if (existing is null)
{
- var existing = people.FirstOrDefault(p =>
- string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase)
- && p.Type == person.Type);
+ people.Add(person);
+ return;
+ }
- // Check for dupes based on the combination of Name and Type
- if (existing is null)
- {
- people.Add(person);
- }
- else
- {
- MergeExisting(existing, person);
- }
+ // If the type is GuestStar and there's already an Actor entry, then promote it to avoid dupes
+ if (person.Type == PersonKind.GuestStar)
+ {
+ existing.Type = PersonKind.GuestStar;
}
+
+ MergeExisting(existing, person);
}
+ private static bool IsSameCredit(PersonInfo existing, PersonInfo person)
+ {
+ if (!string.Equals(existing.Name, person.Name, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // Actor and GuestStar describe the same credit, a guest star is just a promoted actor.
+ if (IsCastKind(existing.Type) && IsCastKind(person.Type))
+ {
+ return true;
+ }
+
+ return existing.Type == person.Type;
+ }
+
+ private static bool IsCastKind(PersonKind kind)
+ => kind is PersonKind.Actor or PersonKind.GuestStar;
+
private static void MergeExisting(PersonInfo existing, PersonInfo person)
{
existing.SortOrder = person.SortOrder ?? existing.SortOrder;
diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs
index 5cc4d322f7..14325d971a 100644
--- a/MediaBrowser.Controller/Entities/Person.cs
+++ b/MediaBrowser.Controller/Entities/Person.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
@@ -75,6 +76,16 @@ namespace MediaBrowser.Controller.Entities
return false;
}
+ /// <inheritdoc />
+ /// <remarks>
+ /// People don't carry the tags of the media they appear in, so the allowed tags check
+ /// is skipped for them; otherwise no person would be visible to users with allowed tags configured.
+ /// </remarks>
+ public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
+ {
+ return base.IsVisible(user, true);
+ }
+
public override bool IsSaveLocalMetadataEnabled()
{
return true;
diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs
index be19e1bdae..97aa22dc04 100644
--- a/MediaBrowser.Controller/Entities/SourceType.cs
+++ b/MediaBrowser.Controller/Entities/SourceType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// The source of an item.
+ /// </summary>
public enum SourceType
{
+ /// <summary>
+ /// The item comes from a library.
+ /// </summary>
Library = 0,
+
+ /// <summary>
+ /// The item comes from a channel.
+ /// </summary>
Channel = 1,
+
+ /// <summary>
+ /// The item comes from live TV.
+ /// </summary>
LiveTV = 2
}
}
diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs
index 42e4f79942..40f917d50c 100644
--- a/MediaBrowser.Controller/Entities/TV/Episode.cs
+++ b/MediaBrowser.Controller/Entities/TV/Episode.cs
@@ -47,7 +47,7 @@ namespace MediaBrowser.Controller.Entities.TV
public int? IndexNumberEnd { get; set; }
[JsonIgnore]
- protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1;
+ protected override bool SupportsOwnedItems => IsStacked || LocalAlternateVersions.Length > 0 || MediaSourceCount > 1;
[JsonIgnore]
public override bool SupportsInheritedParentImages => true;
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index 952187c6e1..1a1da84b7a 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -82,9 +82,15 @@ namespace MediaBrowser.Controller.Entities.TV
{
var userdatakeys = GetUserDataKeys();
- if (userdatakeys.Count > 1)
+ // The first user data key is a stable cross-folder identity.
+ // When none exists, fall back to the (normalized) series name.
+ var groupingKey = userdatakeys.Count > 1
+ ? userdatakeys[0]
+ : GetNameBasedGroupingKey();
+
+ if (!string.IsNullOrEmpty(groupingKey))
{
- return AddLibrariesToPresentationUniqueKey(userdatakeys[0]);
+ return AddLibrariesToPresentationUniqueKey(groupingKey);
}
}
@@ -101,6 +107,7 @@ namespace MediaBrowser.Controller.Entities.TV
var folders = LibraryManager.GetCollectionFolders(this)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
+ .Order(StringComparer.Ordinal)
.ToArray();
if (folders.Length == 0)
@@ -111,6 +118,14 @@ namespace MediaBrowser.Controller.Entities.TV
return key + "-" + string.Join('-', folders);
}
+ private string GetNameBasedGroupingKey()
+ {
+ // Prefix with the type so a series can never collide with a same-named item of another kind.
+ return string.IsNullOrEmpty(Name)
+ ? null
+ : "series-" + Name.ToLowerInvariant();
+ }
+
private static string GetUniqueSeriesKey(BaseItem series)
{
return series.GetPresentationUniqueKey();
@@ -120,20 +135,19 @@ namespace MediaBrowser.Controller.Entities.TV
{
var seriesKey = GetUniqueSeriesKey(this);
- var result = LibraryManager.GetCount(new InternalItemsQuery(user)
+ var result = LibraryManager.GetItemIds(new InternalItemsQuery(user)
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = new[] { BaseItemKind.Season },
IsVirtualItem = false,
- Limit = 0,
DtoOptions = new DtoOptions(false)
{
EnableImages = false
}
});
- return result;
+ return result.Count;
}
public override int GetRecursiveChildCount(User user)
@@ -188,6 +202,25 @@ namespace MediaBrowser.Controller.Entities.TV
return list;
}
+ /// <inheritdoc />
+ protected override Guid[] GetExtraOwnerIds()
+ {
+ if (!LibraryManager.GetLibraryOptions(this).EnableAutomaticSeriesGrouping)
+ {
+ return base.GetExtraOwnerIds();
+ }
+
+ // Setting PresentationUniqueKey on the query disables presentation-key grouping, so this
+ // returns every folder-item of the merged series rather than the collapsed survivor.
+ var ids = LibraryManager.GetItemIds(new InternalItemsQuery
+ {
+ PresentationUniqueKey = GetPresentationUniqueKey(),
+ IncludeItemTypes = [BaseItemKind.Series]
+ });
+
+ return ids.Count == 0 ? base.GetExtraOwnerIds() : ids.ToArray();
+ }
+
public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
{
return GetSeasons(user, new DtoOptions(true));
@@ -507,7 +540,7 @@ namespace MediaBrowser.Controller.Entities.TV
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
- if (!ProductionYear.HasValue)
+ if (ProductionYear is null)
{
var info = LibraryManager.ParseName(Name);
diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs
index 939709215c..a2465eedf0 100644
--- a/MediaBrowser.Controller/Entities/Trailer.cs
+++ b/MediaBrowser.Controller/Entities/Trailer.cs
@@ -49,7 +49,7 @@ namespace MediaBrowser.Controller.Entities
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
- if (!ProductionYear.HasValue)
+ if (ProductionYear is null)
{
var info = LibraryManager.ParseName(Name);
diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs
index deed3631b8..d5be997b84 100644
--- a/MediaBrowser.Controller/Entities/UserRootFolder.cs
+++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs
@@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities
protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
{
- if (query.Recursive)
+ // The user root holds no items of its own - a plain listing returns the user's
+ // views. But a request carrying any filter is a search across the libraries, so
+ // resolve it through the recursive query path even when Recursive wasn't set;
+ // otherwise the filters would be silently dropped. Recursive is set so the
+ // downstream query (ancestor/top-parent scoping) treats it as a recursive search.
+ if (query.Recursive || query.HasFilters)
{
+ query.Recursive = true;
return QueryRecursive(query);
}
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index cb05056601..f9ad2d86e6 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities
case CollectionType.folders:
return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query);
+ case CollectionType.books:
+ return GetBooks(queryParent, user, query);
+
case CollectionType.tvshows:
return GetTvView(queryParent, user, query);
@@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities
return _libraryManager.GetItemsResult(query);
}
+ private QueryResult<BaseItem> GetBooks(Folder parent, User user, InternalItemsQuery query)
+ {
+ query.Recursive = true;
+ query.Parent = parent;
+ query.SetUser(user);
+
+ query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
+
+ return _libraryManager.GetItemsResult(query);
+ }
+
private QueryResult<BaseItem> GetMovieMovies(Folder parent, User user, InternalItemsQuery query)
{
query.Recursive = true;
@@ -447,11 +461,12 @@ namespace MediaBrowser.Controller.Entities
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
var isPlayedValue = query.IsPlayed.Value;
- return itemList.Where(i =>
+ return itemList.Where(item =>
{
- if (i.IsFolder && counts.TryGetValue(i.Id, out var c))
+ if (item is Folder)
{
- return (c.Total > 0 && c.Played == c.Total) == isPlayedValue;
+ var itemCount = counts.GetValueOrDefault(item.Id);
+ return (itemCount.Played >= itemCount.Total) == isPlayedValue;
}
return true;
@@ -476,6 +491,13 @@ namespace MediaBrowser.Controller.Entities
}
var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray();
+
+ // Adjacency is defined by the order the query asked for, so it has to run after sorting but before paging.
+ if (!query.AdjacentTo.IsNullOrEmpty())
+ {
+ itemsArray = FilterForAdjacency(itemsArray, query.AdjacentTo.Value).ToArray();
+ }
+
var totalCount = itemsArray.Length;
if (query.Limit.HasValue && query.Limit.Value > 0)
@@ -716,7 +738,7 @@ namespace MediaBrowser.Controller.Entities
// Apply year filter
if (query.Years.Length > 0)
{
- if (!(item.ProductionYear.HasValue && query.Years.Contains(item.ProductionYear.Value)))
+ if (item.ProductionYear is null || !query.Years.Contains(item.ProductionYear.Value))
{
return false;
}
@@ -872,26 +894,32 @@ namespace MediaBrowser.Controller.Entities
return _userViewManager.GetUserSubView(parent.Id, type, localizationKey, sortName);
}
- public static IEnumerable<BaseItem> FilterForAdjacency(List<BaseItem> list, Guid adjacentTo)
+ /// <summary>
+ /// Trims an ordered list down to the requested item and its immediate neighbours.
+ /// </summary>
+ /// <param name="list">The items in the order the query returned them.</param>
+ /// <param name="adjacentTo">The id of the item to return the neighbours of.</param>
+ /// <returns>The previous item, the requested item and the next item, in order.</returns>
+ public static IEnumerable<BaseItem> FilterForAdjacency(IReadOnlyList<BaseItem> list, Guid adjacentTo)
{
- var adjacentToItem = list.FirstOrDefault(i => i.Id.Equals(adjacentTo));
-
- var index = list.IndexOf(adjacentToItem);
-
- var previousId = Guid.Empty;
- var nextId = Guid.Empty;
-
- if (index > 0)
+ var index = -1;
+ for (var i = 0; i < list.Count; i++)
{
- previousId = list[index - 1].Id;
+ if (list[i].Id.Equals(adjacentTo))
+ {
+ index = i;
+ break;
+ }
}
- if (index < list.Count - 1)
+ // The item isn't part of this result set, so it has no neighbours in it either.
+ if (index < 0)
{
- nextId = list[index + 1].Id;
+ return [];
}
- return list.Where(i => i.Id.Equals(previousId) || i.Id.Equals(nextId) || i.Id.Equals(adjacentTo));
+ var start = Math.Max(index - 1, 0);
+ return list.Skip(start).Take(Math.Min(index + 2, list.Count) - start);
}
}
}
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index e7a5672ebd..e2f91aa04a 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -34,11 +34,11 @@ namespace MediaBrowser.Controller.Entities
{
public Video()
{
- AdditionalParts = Array.Empty<string>();
- LocalAlternateVersions = Array.Empty<string>();
- SubtitleFiles = Array.Empty<string>();
- AudioFiles = Array.Empty<string>();
- LinkedAlternateVersions = Array.Empty<LinkedChild>();
+ AdditionalParts = [];
+ LocalAlternateVersions = [];
+ SubtitleFiles = [];
+ AudioFiles = [];
+ LinkedAlternateVersions = [];
}
[JsonIgnore]
@@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities
private int GetMediaSourceCount(HashSet<Guid> callstack = null)
{
- callstack ??= new();
+ callstack ??= [];
if (PrimaryVersionId.HasValue)
{
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
@@ -335,6 +335,102 @@ namespace MediaBrowser.Controller.Entities
PresentationUniqueKey = CreatePresentationUniqueKey();
}
+ /// <summary>
+ /// Marks the played status of this video and propagates it to its alternate versions.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="datePlayed">The date played.</param>
+ /// <param name="resetPosition">if set to <c>true</c> [reset position].</param>
+ public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
+ {
+ base.MarkPlayed(user, datePlayed, resetPosition);
+ PropagatePlayedState(user, true, resetPosition);
+ }
+
+ /// <summary>
+ /// Marks this video unplayed and propagates the change to its alternate versions.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ public override void MarkUnplayed(User user)
+ {
+ base.MarkUnplayed(user);
+
+ // MarkUnplayed always clears the position on this video, so reset the versions too.
+ PropagatePlayedState(user, false, true);
+ }
+
+ /// <summary>
+ /// Propagates the played status to every alternate version of this video.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="played">The played status to apply to the alternate versions.</param>
+ /// <param name="resetPosition">When marking played, controls whether each version's resume point
+ /// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed,
+ /// which always fully resets every version.</param>
+ public void PropagatePlayedState(User user, bool played, bool resetPosition = true)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions)
+ {
+ return;
+ }
+
+ foreach (var (item, _) in GetAllItemsForMediaSources())
+ {
+ if (item.Id.Equals(Id) || item is not Video)
+ {
+ continue;
+ }
+
+ if (played)
+ {
+ var dto = new UpdateUserItemDataDto { Played = true };
+ if (resetPosition)
+ {
+ dto.PlaybackPositionTicks = 0;
+ }
+
+ // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved.
+ UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed);
+ }
+ else
+ {
+ var data = UserDataManager.GetUserData(user, item);
+ if (data is null)
+ {
+ continue;
+ }
+
+ ResetPlayedState(data);
+ UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets this video together with all of its alternate versions (local and linked and, when this
+ /// is itself an alternate, the primary and the primary's other versions), deduplicated.
+ /// </summary>
+ /// <returns>This video and every alternate version of it.</returns>
+ public IReadOnlyList<Video> GetAllVersions()
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item)
+ .OfType<Video>()
+ .ToList();
+ }
+
+ /// <summary>
+ /// Gets the alternate version of this video that matches the supplied item id.
+ /// </summary>
+ /// <param name="itemId">The version item id (the playback media source id).</param>
+ /// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns>
+ public Video GetAlternateVersion(Guid itemId)
+ {
+ return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId));
+ }
+
public override string CreatePresentationUniqueKey()
{
if (PrimaryVersionId.HasValue)
@@ -396,8 +492,8 @@ namespace MediaBrowser.Controller.Entities
public IOrderedEnumerable<Video> GetAdditionalParts(User user = null)
{
return GetAdditionalPartIds()
- .Select(i => LibraryManager.GetItemById<Video>(i, user))
- .Where(i => i is not null)
+ .Select(i => LibraryManager.GetItemById<Video>(i))
+ .Where(i => i is not null && (user is null || i.IsVisible(user)))
.OrderBy(i => i.SortName);
}
@@ -431,7 +527,13 @@ namespace MediaBrowser.Controller.Entities
protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
- var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
+ var hasChanges = false;
+
+ // The extras of a version group are maintained by its primary.
+ if (!PrimaryVersionId.HasValue)
+ {
+ hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
+ }
// Clean up LocalAlternateVersions - remove paths that no longer exist
if (LocalAlternateVersions.Length > 0)
@@ -492,10 +594,20 @@ namespace MediaBrowser.Controller.Entities
{
altVideo.OwnerId = Id;
altVideo.SetPrimaryVersionId(Id);
+ altVideo.IsInMixedFolder = IsInMixedFolder;
LibraryManager.CreateItem(altVideo, GetParent());
}
}
+ // A version is resolved on its own, so it does not learn whether the folder it sits in
+ // holds other items. It has to share that with the version it belongs to, before the
+ // refresh below acts on it.
+ if (LibraryManager.GetItemById(id) is Video resolvedVersion && resolvedVersion.IsInMixedFolder != IsInMixedFolder)
+ {
+ resolvedVersion.IsInMixedFolder = IsInMixedFolder;
+ await resolvedVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
+ }
+
await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false);
// Create LinkedChild entry for this local alternate version
@@ -575,6 +687,7 @@ namespace MediaBrowser.Controller.Entities
video.Id = id;
video.OwnerId = Id;
+ video.IsInMixedFolder = IsInMixedFolder;
LibraryManager.CreateItem(video, parentFolder);
newOptions.ForceSave = true;
}
@@ -642,39 +755,132 @@ namespace MediaBrowser.Controller.Entities
}).FirstOrDefault();
}
- protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
+ /// <summary>
+ /// Gets the ids of the items whose owned extras belong to this item.
+ /// Extras are linked to a single version but need tp be surfaced for all versions.
+ /// </summary>
+ /// <returns>An array containing the owner ids.</returns>
+ protected override Guid[] GetExtraOwnerIds()
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item.Id)
+ .Distinct()
+ .ToArray();
+ }
+
+ /// <inheritdoc />
+ protected override Guid[] GetOwnedVersionIds()
+ {
+ // Only the versions that live beside this one in the folder this scan covers. Linked
+ // versions are items of their own and maintain their extras themselves.
+ return [Id, .. LibraryManager.GetLocalAlternateVersionIds(this)];
+ }
+
+ /// <inheritdoc />
+ protected override Guid GetOwnerIdForExtra(BaseItem extra)
{
- var list = new List<(BaseItem, MediaSourceType)>
+ if (string.IsNullOrEmpty(extra.Path))
{
- (this, MediaSourceType.Default)
- };
+ return Id;
+ }
- list.AddRange(
- LibraryManager.GetLinkedAlternateVersions(this)
- .Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
+ var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan());
+ var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan());
- if (PrimaryVersionId.HasValue)
+ var ownerId = Id;
+ var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName);
+
+ foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this))
{
- if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
+ var version = LibraryManager.GetItemById(versionId);
+ if (version is null)
{
- var existingIds = list.Select(i => i.Item1.Id).ToList();
- list.Add((primary, MediaSourceType.Grouping));
- list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
+ continue;
}
- }
- var localAlternates = list
- .SelectMany(i =>
+ // "Movie - [2160p]-trailer.mkv" belongs to "Movie - [2160p].mkv" rather than to the
+ // primary version, whose name it also starts with when the primary is plain "Movie.mkv"
+ var length = MatchedVersionNameLength(version.Path, extraDirectory, extraFileName);
+ if (length > matchedLength)
{
- return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
- })
- .Select(LibraryManager.GetItemById)
- .Where(i => i is not null)
+ matchedLength = length;
+ ownerId = versionId;
+ }
+ }
+
+ return ownerId;
+ }
+
+ /// <summary>
+ /// Gets how much of an extra's file name is the name of the given version file, or 0 when the
+ /// extra is not named after it.
+ /// </summary>
+ /// <param name="versionPath">The path of the version.</param>
+ /// <param name="extraDirectory">The directory the extra lives in.</param>
+ /// <param name="extraFileName">The file name of the extra, without extension.</param>
+ /// <returns>The length of the match.</returns>
+ private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan<char> extraDirectory, ReadOnlySpan<char> extraFileName)
+ {
+ if (string.IsNullOrEmpty(versionPath)
+ || !System.IO.Path.GetDirectoryName(versionPath.AsSpan()).Equals(extraDirectory, StringComparison.OrdinalIgnoreCase))
+ {
+ return 0;
+ }
+
+ var versionFileName = System.IO.Path.GetFileNameWithoutExtension(versionPath.AsSpan());
+ if (versionFileName.IsEmpty || !extraFileName.StartsWith(versionFileName, StringComparison.OrdinalIgnoreCase))
+ {
+ return 0;
+ }
+
+ // The version name has to end where the extra's own name begins, so that a version
+ // named "Movie - 4K" does not claim the extras of "Movie - 4Kish"
+ var remainder = extraFileName[versionFileName.Length..];
+
+ return !remainder.IsEmpty && (remainder[0] == ' ' || Array.IndexOf(VersionDelimiters, remainder[0]) >= 0)
+ ? versionFileName.Length
+ : 0;
+ }
+
+ protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
+ {
+ var primary = PrimaryVersionId.HasValue
+ ? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
+ : null;
+
+ var primaryLinked = primary is null
+ ? []
+ : LibraryManager.GetLinkedAlternateVersions(primary).ToList();
+
+ // Grouping marks user-merged (splittable) sources. The primary is only such a source when
+ // this video is linked onto it; for local (file-based) alternates the primary is just
+ // another default source.
+ var primaryType = primaryLinked.Any(i => i.Id.Equals(Id))
+ ? MediaSourceType.Grouping
+ : MediaSourceType.Default;
+
+ // This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates.
+ var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) }
+ .Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
+ .Concat(primary is null
+ ? []
+ : primaryLinked.Select(i => ((BaseItem)i, MediaSourceType.Grouping)).Prepend(((BaseItem)primary, primaryType)))
.ToList();
- list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default)));
+ // The local (file-based) alternate versions of every grouped item.
+ var localAlternates = grouped
+ .Select(i => i.Item1)
+ .OfType<Video>()
+ .SelectMany(LibraryManager.GetLocalAlternateVersionIds)
+ .Select(LibraryManager.GetItemById)
+ .Where(i => i is not null)
+ .Select(i => (i, MediaSourceType.Default));
- return list;
+ // Deduplicate
+ return grouped
+ .Concat(localAlternates)
+ .DistinctBy(i => i.Item1.Id)
+ .ToList();
}
}
}
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/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 0b64da291c..ca686fbd9d 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -256,6 +256,14 @@ namespace MediaBrowser.Controller.Library
IEnumerable<Video> GetLinkedAlternateVersions(Video video);
/// <summary>
+ /// Gets, in a single query, the subset of the supplied items that own at least one alternate
+ /// version (local or linked). Items absent from the result have no alternate versions.
+ /// </summary>
+ /// <param name="itemIds">The item IDs to check.</param>
+ /// <returns>The set of item IDs that have alternate versions.</returns>
+ IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds);
+
+ /// <summary>
/// Creates or updates a LinkedChild entry linking a parent to a child item.
/// </summary>
/// <param name="parentId">The parent item ID.</param>
@@ -606,6 +614,13 @@ namespace MediaBrowser.Controller.Library
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
/// <summary>
+ /// Gets the people for multiple items in a single query, keyed by item id.
+ /// </summary>
+ /// <param name="itemIds">The item IDs.</param>
+ /// <returns>A dictionary mapping each item ID to its people. Items with no people are omitted.</returns>
+ IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds);
+
+ /// <summary>
/// Queries the items.
/// </summary>
/// <param name="query">The query.</param>
@@ -799,5 +814,13 @@ namespace MediaBrowser.Controller.Library
/// <param name="mediaStreamType">The stream type.</param>
/// <returns>List of language codes.</returns>
IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType);
+
+ /// <summary>
+ /// Gets a list of all language codes for the matching items and the the provided stream type.
+ /// </summary>
+ /// <param name="mediaStreamType">The stream type.</param>
+ /// <param name="query">The query filter.</param>
+ /// <returns>List of language codes.</returns>
+ IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query);
}
}
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/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs
index 798812bf1f..2ee8845346 100644
--- a/MediaBrowser.Controller/Library/IUserDataManager.cs
+++ b/MediaBrowser.Controller/Library/IUserDataManager.cs
@@ -63,6 +63,24 @@ namespace MediaBrowser.Controller.Library
Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user);
/// <summary>
+ /// Gets the user data that should drive resume for a multi-version item: the data of the most
+ /// recently played alternate version (including the item itself) that has a resume point.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="item">The item.</param>
+ /// <returns>The resume version's data, or <c>null</c> when the item has no versions or none has a resume point.</returns>
+ VersionResumeData? GetResumeUserData(User user, BaseItem item);
+
+ /// <summary>
+ /// Gets the resume-driving user data for multiple items in a single batch operation.
+ /// See <see cref="GetResumeUserData(User, BaseItem)"/>.
+ /// </summary>
+ /// <param name="items">The items to get resume data for.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>A dictionary mapping item ids to their resume version's data; items without one are omitted.</returns>
+ IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user);
+
+ /// <summary>
/// Gets the user data dto.
/// </summary>
/// <param name="item">Item to use.</param>
@@ -80,5 +98,13 @@ namespace MediaBrowser.Controller.Library
/// <param name="reportedPositionTicks">New playstate.</param>
/// <returns>True if playstate was updated.</returns>
bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks);
+
+ /// <summary>
+ /// Clears any stored audio and subtitle stream selections for the given user/item pair.
+ /// Used when the user has opted out of remembering selections.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="item">The item.</param>
+ void ResetPlaybackStreamSelections(User user, BaseItem item);
}
}
diff --git a/MediaBrowser.Controller/Library/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs
new file mode 100644
index 0000000000..b1ff800fa0
--- /dev/null
+++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs
@@ -0,0 +1,51 @@
+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. An empty array means every type is eligible.
+ /// When this is non-empty it is the authoritative type filter and <see cref="ExcludeItemTypes"/>
+ /// does not apply; excludes only take effect when no include types were requested.
+ /// </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. This is an additional constraint rather than
+ /// an alternative one: a provider must return only items that match both the requested media
+ /// types and the requested item types, not the union of the two.
+ /// </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. This scopes to the whole subtree, not just direct
+ /// children - callers routinely pass a library folder id and expect items nested arbitrarily
+ /// deep beneath it (an episode under a season under a series) to match.
+ /// </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/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/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index ff8d84d45e..10c21ee03c 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;
@@ -444,6 +445,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
}
+ private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
+ {
+ var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
+ var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
+ return doDeintH264 || doDeintHevc;
+ }
+
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
{
var videoStream = state.VideoStream;
@@ -1310,7 +1318,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(canvasArgs);
}
- arg.Append(" -i file:\"").Append(subtitlePath).Append('\"');
+ arg.Append(" -i file:\"").Append(subtitlePath.EscapeProcessArgument()).Append('\"');
}
if (state.AudioStream is not null && state.AudioStream.IsExternal)
@@ -1322,7 +1330,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(' ').Append(seekAudioParam);
}
- arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"');
+ arg.Append(" -i \"").Append(state.AudioStream.Path.EscapeProcessArgument()).Append('"');
}
// Disable auto inserted SW scaler for HW decoders in case of changed resolution.
@@ -2604,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
@@ -2661,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)
@@ -3850,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;
@@ -4004,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);
@@ -4040,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);
@@ -4215,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);
@@ -4463,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;
@@ -4757,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;
@@ -5088,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;
@@ -5325,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;
@@ -5565,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);
@@ -5798,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);
@@ -5999,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);
@@ -6265,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;
@@ -7221,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";
@@ -7236,6 +7244,13 @@ namespace MediaBrowser.Controller.MediaEncoding
{
state.OutputAudioCodec = "copy";
}
+ else if (state.AudioStream is not null && !IsCopyCodec(state.OutputAudioCodec))
+ {
+ // Audio is actually being re-encoded although the playback determination may have considered the source copyable.
+ // Only carry the primary "cannot be passed through" cause - the codec mismatch.
+ // Bitrate/channels/sample-rate/bit-depth copy refusals are consequences of the chosen transcode target.
+ state.AddTranscodeReason(audioCopyFailureReasons & TranscodeReason.AudioCodecNotSupported);
+ }
}
}
@@ -7849,19 +7864,26 @@ namespace MediaBrowser.Controller.MediaEncoding
audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state));
}
- if (GetAudioEncoder(state).StartsWith("pcm_", StringComparison.Ordinal))
+ // The pcm_* encoders emit raw samples that carry no header of their own, so the header
+ // has to come from the muxer. Only force the matching raw muxer when the client actually
+ // asked for a raw container (added in #10321 for I2S/MCU clients): applying it to every
+ // pcm_* codec also strips the RIFF header from a `stream.wav` request, which then serves
+ // headerless PCM behind an audio/wav content type.
+ var audioEncoder = GetAudioEncoder(state);
+ if (audioEncoder.StartsWith("pcm_", StringComparison.Ordinal)
+ && string.Equals(state.OutputContainer, "pcm", StringComparison.OrdinalIgnoreCase))
{
- audioTranscodeParams.Add(string.Concat("-f ", GetAudioEncoder(state).AsSpan(4)));
- audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
+ audioTranscodeParams.Add(string.Concat("-f ", audioEncoder.AsSpan(4)));
}
- 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,
@@ -7869,9 +7891,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
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index 3a1897a244..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);
diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs
index 56990d0b82..5045030b9b 100644
--- a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs
+++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs
@@ -15,6 +15,7 @@ public sealed class TranscodingJob : IDisposable
private readonly Lock _processLock = new();
private readonly Lock _timerLock = new();
+ private int _activeRequestCount;
private Timer? _killTimer;
/// <summary>
@@ -64,7 +65,11 @@ public sealed class TranscodingJob : IDisposable
/// <summary>
/// Gets or sets the active request count.
/// </summary>
- public int ActiveRequestCount { get; set; }
+ public int ActiveRequestCount
+ {
+ get => Volatile.Read(ref _activeRequestCount);
+ set => Volatile.Write(ref _activeRequestCount, value);
+ }
/// <summary>
/// Gets or sets device id.
@@ -152,6 +157,20 @@ public sealed class TranscodingJob : IDisposable
public int PingTimeout { get; set; }
/// <summary>
+ /// Increments the active request count.
+ /// </summary>
+ /// <returns>The incremented count.</returns>
+ public int IncrementActiveRequestCount()
+ => Interlocked.Increment(ref _activeRequestCount);
+
+ /// <summary>
+ /// Decrements the active request count.
+ /// </summary>
+ /// <returns>The decremented count.</returns>
+ public int DecrementActiveRequestCount()
+ => Interlocked.Decrement(ref _activeRequestCount);
+
+ /// <summary>
/// Stop kill timer.
/// </summary>
public void StopKillTimer()
diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs
index 2e29cbdbba..f9a050d591 100644
--- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs
+++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
+using System.Linq.Expressions;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
@@ -79,17 +80,26 @@ public interface IItemQueryHelpers
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.
+ /// Builds a query for the playable leaf items a user can access.
/// </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(
+ /// <param name="user">The user to filter accessible items for.</param>
+ /// <param name="includeOwnedItems">Whether to include alternate versions and owned items.</param>
+ /// <returns>The access-filtered leaf item queryable.</returns>
+ IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(
JellyfinDbContext context,
- IQueryable<Guid> folderIds,
- User user);
+ User user,
+ bool includeOwnedItems = false);
+
+ /// <summary>
+ /// Builds a filter matching items that have at least one of <paramref name="descendants"/> below them.
+ /// </summary>
+ /// <param name="context">The database context the resulting filter is bound to.</param>
+ /// <param name="descendants">A query yielding the descendants to look for.</param>
+ /// <returns>A filter expression matching items with a matching descendant.</returns>
+ Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> descendants);
/// <summary>
/// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>.
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index 291916ab25..d44fe57bed 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -7,6 +7,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Persistence;
@@ -120,6 +121,14 @@ public interface IItemRepository
IReadOnlyList<string> GetGenreNames();
/// <summary>
+ /// Gets all language codes of the matching base items and the provided stream type.
+ /// </summary>
+ /// <param name="filter">The query filter.</param>
+ /// <param name="mediaStreamType">The type of the media stream.</param>
+ /// <returns>List of language codes.</returns>
+ public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType);
+
+ /// <summary>
/// Gets all artist names.
/// </summary>
/// <returns>The list of artist names.</returns>
diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
index a4614fc125..79c29410e4 100644
--- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
+++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
@@ -20,6 +20,15 @@ public interface ILinkedChildrenService
IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null);
/// <summary>
+ /// Gets, in a single query, the subset of the supplied items that own at least one alternate
+ /// version (local or linked). Items absent from the result have no alternate versions, so their
+ /// media source count is one.
+ /// </summary>
+ /// <param name="itemIds">The item IDs to check.</param>
+ /// <returns>The set of item IDs that have alternate versions.</returns>
+ IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds);
+
+ /// <summary>
/// Gets all artist matches from the database.
/// </summary>
/// <param name="artistNames">The names of the artists.</param>
diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
index e2833dc722..9811241d31 100644
--- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
@@ -40,4 +40,11 @@ public interface IPeopleRepository
/// <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);
+
+ /// <summary>
+ /// Gets the people for multiple items in a single query, keyed by item id.
+ /// </summary>
+ /// <param name="itemIds">The item IDs to get people for.</param>
+ /// <returns>A dictionary mapping each item ID to its people (with role, type and sort order), ordered by cast list order. Items with no people are omitted.</returns>
+ IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds);
}
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/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/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs
index ef69885fcf..48fc22a0fb 100644
--- a/MediaBrowser.Controller/Providers/MetadataResult.cs
+++ b/MediaBrowser.Controller/Providers/MetadataResult.cs
@@ -16,11 +16,6 @@ namespace MediaBrowser.Controller.Providers
private List<(string Url, ImageType Type)> _remoteImages;
private List<PersonInfo> _people;
- public MetadataResult()
- {
- ResultLanguage = "en";
- }
-
public List<LocalImageInfo> Images
{
get => _images ??= [];
@@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers
public T Item { get; set; }
+ /// <summary>
+ /// Gets or sets the language the fetched metadata is in.
+ /// </summary>
public string ResultLanguage { get; set; }
public string Provider { get; set; }
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 fb68bfb770..d9bc79dec5 100644
--- a/MediaBrowser.Controller/Session/SessionInfo.cs
+++ b/MediaBrowser.Controller/Session/SessionInfo.cs
@@ -28,6 +28,7 @@ namespace MediaBrowser.Controller.Session
private readonly Lock _progressLock = new();
private Timer _progressTimer;
private PlaybackProgressInfo _lastProgressInfo;
+ private long? _lastPlaybackCheckInPositionTicks;
private bool _disposed;
@@ -125,6 +126,22 @@ namespace MediaBrowser.Controller.Session
public DateTime LastPlaybackCheckIn { get; set; }
/// <summary>
+ /// Gets the position reported by the client at the last playback check-in.
+ /// </summary>
+ /// <value>The position ticks, or <see langword="null"/> if the client did not report a position.</value>
+ [JsonIgnore]
+ public long? LastPlaybackCheckInPositionTicks
+ {
+ get
+ {
+ lock (_progressLock)
+ {
+ return _lastPlaybackCheckInPositionTicks;
+ }
+ }
+ }
+
+ /// <summary>
/// Gets or sets the last paused date.
/// </summary>
/// <value>The last paused date.</value>
@@ -372,6 +389,7 @@ namespace MediaBrowser.Controller.Session
lock (_progressLock)
{
+ _lastPlaybackCheckInPositionTicks = progressInfo.PositionTicks;
_lastProgressInfo = progressInfo;
if (_progressTimer is null)
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
index c0a168192e..9326864d78 100644
--- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -272,7 +272,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
public void SetPlayingItemByIndex(int playlistIndex)
{
var playlist = GetPlaylistInternal();
- if (playlistIndex < 0 || playlistIndex > playlist.Count)
+ if (playlistIndex < 0 || playlistIndex >= playlist.Count)
{
PlayingItemIndex = NoPlayingItemIndex;
}
@@ -293,6 +293,15 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
{
var playingItem = GetPlayingItem();
+ // Removed items that precede the playing item shift its index as well.
+ var removedBeforePlayingItem = 0;
+ if (playingItem is not null)
+ {
+ removedBeforePlayingItem = GetPlaylistInternal()
+ .Take(PlayingItemIndex)
+ .Count(item => playlistItemIds.Contains(item.PlaylistItemId));
+ }
+
_sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
_shuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
@@ -303,12 +312,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
if (playlistItemIds.Contains(playingItem.PlaylistItemId))
{
// Playing item has been removed, picking previous item.
- PlayingItemIndex--;
+ PlayingItemIndex -= removedBeforePlayingItem + 1;
if (PlayingItemIndex < 0)
{
// Was first element, picking next if available.
// Default to no playing item otherwise.
- PlayingItemIndex = _sortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex;
+ PlayingItemIndex = GetPlaylistInternal().Count > 0 ? 0 : NoPlayingItemIndex;
}
return true;
@@ -444,6 +453,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
public bool Next()
{
+ if (GetPlaylistInternal().Count == 0)
+ {
+ return false;
+ }
+
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
@@ -474,6 +488,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
public bool Previous()
{
+ if (GetPlaylistInternal().Count == 0)
+ {
+ return false;
+ }
+
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;