aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci-codeql-analysis.yml2
-rw-r--r--.github/workflows/ci-compat.yml4
-rw-r--r--.github/workflows/ci-format.yml2
-rw-r--r--.github/workflows/ci-tests.yml2
-rw-r--r--.github/workflows/openapi-generate.yml2
-rw-r--r--.github/workflows/pull-request-conflict.yml4
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs25
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs6
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs292
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs215
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs56
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs71
-rw-r--r--Jellyfin.Api/Controllers/MoviesController.cs281
-rw-r--r--Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs23
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs27
-rw-r--r--MediaBrowser.Controller/Collections/ICollectionManager.cs8
-rw-r--r--MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs26
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs9
-rw-r--r--MediaBrowser.Controller/Library/ISimilarItemsManager.cs20
-rw-r--r--MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs32
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs69
-rw-r--r--MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs4
-rw-r--r--MediaBrowser.Controller/Persistence/IPeopleRepository.cs9
-rw-r--r--MediaBrowser.Model/Configuration/EncodingOptions.cs4
-rw-r--r--MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs9
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs47
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs99
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs1
28 files changed, 989 insertions, 360 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index cf4cc1c7f1..8d3e8fe6d5 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -27,7 +27,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '10.0.x'
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index dd48209a1f..99aa56f54f 100644
--- a/.github/workflows/ci-compat.yml
+++ b/.github/workflows/ci-compat.yml
@@ -17,7 +17,7 @@ jobs:
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '10.0.x'
@@ -47,7 +47,7 @@ jobs:
fetch-depth: 0
- name: Setup .NET
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '10.0.x'
diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml
index c2cca262bf..d6ba603fbb 100644
--- a/.github/workflows/ci-format.yml
+++ b/.github/workflows/ci-format.yml
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 3c7ba54acf..928249d93e 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -22,7 +22,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml
index dbfaf9d30b..c2a5199c54 100644
--- a/.github/workflows/openapi-generate.yml
+++ b/.github/workflows/openapi-generate.yml
@@ -28,7 +28,7 @@ jobs:
repository: ${{ inputs.repository }}
- name: Configure .NET
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '10.0.x'
diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml
index 32628ac912..3f6af02a48 100644
--- a/.github/workflows/pull-request-conflict.yml
+++ b/.github/workflows/pull-request-conflict.yml
@@ -15,8 +15,8 @@ jobs:
if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }}
steps:
- name: Apply label
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
- if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
+ uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
+ if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index 0ede5665f9..295efd456c 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -4,12 +4,15 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections
private readonly ILibraryMonitor _iLibraryMonitor;
private readonly ILogger<CollectionManager> _logger;
private readonly IProviderManager _providerManager;
+ private readonly ILinkedChildrenService _linkedChildrenService;
private readonly ILocalizationManager _localizationManager;
private readonly IApplicationPaths _appPaths;
@@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections
/// <param name="iLibraryMonitor">The library monitor.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="providerManager">The provider manager.</param>
+ /// <param name="linkedChildrenService">The linked children service.</param>
public CollectionManager(
ILibraryManager libraryManager,
IApplicationPaths appPaths,
@@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections
IFileSystem fileSystem,
ILibraryMonitor iLibraryMonitor,
ILoggerFactory loggerFactory,
- IProviderManager providerManager)
+ IProviderManager providerManager,
+ ILinkedChildrenService linkedChildrenService)
{
_libraryManager = libraryManager;
_fileSystem = fileSystem;
_iLibraryMonitor = iLibraryMonitor;
_logger = loggerFactory.CreateLogger<CollectionManager>();
_providerManager = providerManager;
+ _linkedChildrenService = linkedChildrenService;
_localizationManager = localizationManager;
_appPaths = appPaths;
}
@@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
}
+ /// <inheritdoc />
+ public IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (itemId.IsEmpty())
+ {
+ return Enumerable.Empty<BoxSet>();
+ }
+
+ return _linkedChildrenService
+ .GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet)
+ .Select(parentId => _libraryManager.GetItemById<BoxSet>(parentId, user))
+ .OfType<BoxSet>();
+ }
+
private IEnumerable<BoxSet> GetCollections(User user)
{
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 30ff1bd333..cc85f09d23 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -3394,6 +3394,12 @@ namespace Emby.Server.Implementations.Library
return _peopleRepository.GetPeopleNames(query);
}
+ /// <inheritdoc/>
+ public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
+ {
+ return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit);
+ }
+
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
{
UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult();
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
index 93aa0574c0..b4ed12a20c 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -1,36 +1,72 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
-using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
namespace Emby.Server.Implementations.Library.SimilarItems;
/// <summary>
-/// Provides similar items for movies and trailers.
+/// Provides similar items for movies and trailers using weighted scoring.
/// </summary>
-public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>
+public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, IBatchLocalSimilarItemsProvider
{
- private readonly ILibraryManager _libraryManager;
+ private const int GenreWeight = 10;
+ private const int TagWeight = 5;
+ private const int StudioWeight = 5;
+ private const int DirectorWeight = 50;
+ private const int ActorWeight = 15;
+
+ // Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id
+ // load, navigation includes) stay bounded regardless of caller input.
+ private const int MaxBatchSourceItems = 64;
+
+ private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions =
+ [
+ (ItemValueType.Genre, GenreWeight),
+ (ItemValueType.Tags, TagWeight),
+ (ItemValueType.Studios, StudioWeight)
+ ];
+
+ private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal)
+ {
+ [nameof(PersonKind.Director)] = DirectorWeight,
+ [nameof(PersonKind.Actor)] = ActorWeight,
+ [nameof(PersonKind.GuestStar)] = ActorWeight,
+ };
+
+ private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys];
+
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IItemQueryHelpers _queryHelpers;
private readonly IServerConfigurationManager _serverConfigurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
/// </summary>
- /// <param name="libraryManager">The library manager.</param>
+ /// <param name="dbProvider">The database context factory.</param>
+ /// <param name="queryHelpers">The shared query helpers.</param>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
public MovieSimilarItemsProvider(
- ILibraryManager libraryManager,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IItemQueryHelpers queryHelpers,
IServerConfigurationManager serverConfigurationManager)
{
- _libraryManager = libraryManager;
+ _dbProvider = dbProvider;
+ _queryHelpers = queryHelpers;
_serverConfigurationManager = serverConfigurationManager;
}
@@ -41,15 +77,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
/// <inheritdoc/>
- public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
+ public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
{
- return Task.FromResult(GetSimilarMovieItems(item, query));
+ var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
+ return results.TryGetValue(item.Id, out var items) ? items : [];
}
/// <inheritdoc/>
- public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
+ public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
{
- return Task.FromResult(GetSimilarMovieItems(item, query));
+ var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
+ return results.TryGetValue(item.Id, out var items) ? items : [];
}
bool ILocalSimilarItemsProvider.Supports(Type itemType)
@@ -63,29 +101,233 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
_ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
};
- private IReadOnlyList<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query)
+ /// <inheritdoc/>
+ public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
+ IReadOnlyList<BaseItemDto> sourceItems,
+ SimilarItemsQuery query,
+ CancellationToken cancellationToken)
{
var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
-
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
includeItemTypes.Add(BaseItemKind.Trailer);
includeItemTypes.Add(BaseItemKind.LiveTvProgram);
}
- var internalQuery = new InternalItemsQuery(query.User)
+ var limit = query.Limit ?? 50;
+ var dtoOptions = query.DtoOptions ?? new DtoOptions();
+
+ if (sourceItems.Count > MaxBatchSourceItems)
{
- Genres = item.Genres,
- Tags = item.Tags,
- Limit = query.Limit,
- DtoOptions = query.DtoOptions ?? new DtoOptions(),
- ExcludeItemIds = [.. query.ExcludeItemIds],
- IncludeItemTypes = [.. includeItemTypes],
- EnableGroupByMetadataKey = true,
- EnableTotalRecordCount = false,
- OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
- };
+ sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
+ }
+
+ var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ // Phase 1: Score all candidates per source item
+ var sourceIds = sourceItems.Select(i => i.Id).ToList();
+ var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false);
+
+ var allCandidateIds = new HashSet<Guid>();
+ foreach (var (_, scores) in perSourceScores)
+ {
+ allCandidateIds.UnionWith(
+ scores.OrderByDescending(kvp => kvp.Value)
+ .Take(limit * 3)
+ .Select(kvp => kvp.Key));
+ }
+
+ var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
+ if (allCandidateIds.Count == 0)
+ {
+ return result;
+ }
+
+ // Phase 2: One access filter for all candidates
+ var filter = new InternalItemsQuery(query.User)
+ {
+ IncludeItemTypes = [.. includeItemTypes],
+ ExcludeItemIds = [.. query.ExcludeItemIds],
+ DtoOptions = dtoOptions,
+ EnableGroupByMetadataKey = true,
+ EnableTotalRecordCount = false,
+ IsMovie = true,
+ IsPlayed = false
+ };
+
+ _queryHelpers.PrepareFilterQuery(filter);
+ var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
+ baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
+
+ var allCandidateIdsList = allCandidateIds.ToList();
+ var accessibleItems = await baseQuery
+ .WhereOneOrMany(allCandidateIdsList, e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey })
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
+ var allOrderedIds = new HashSet<Guid>();
+ var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
+
+ foreach (var item in sourceItems)
+ {
+ if (!perSourceScores.TryGetValue(item.Id, out var scores))
+ {
+ continue;
+ }
+
+ var orderedIds = accessibleItems
+ .Where(x => scores.ContainsKey(x.Id))
+ .OrderByDescending(x => scores.GetValueOrDefault(x.Id))
+ .DistinctBy(x => x.PresentationUniqueKey)
+ .Take(limit)
+ .Select(x => x.Id)
+ .ToList();
+
+ if (orderedIds.Count > 0)
+ {
+ perSourceOrderedIds[item.Id] = orderedIds;
+ allOrderedIds.UnionWith(orderedIds);
+ }
+ }
+
+ if (allOrderedIds.Count == 0)
+ {
+ return result;
+ }
+
+ // Phase 4: One entity load for all results
+ var allOrderedIdsList = allOrderedIds.ToList();
+ var entities = await _queryHelpers.ApplyNavigations(
+ context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id),
+ filter)
+ .AsSplitQuery()
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ var entitiesById = entities
+ .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization))
+ .Where(dto => dto is not null)
+ .ToDictionary(i => i!.Id);
+
+ // Phase 5: Split by source, preserving score order
+ foreach (var (sourceId, orderedIds) in perSourceOrderedIds)
+ {
+ var items = orderedIds
+ .Where(entitiesById.ContainsKey)
+ .Select(id => entitiesById[id]!)
+ .ToList();
+
+ if (items.Count > 0)
+ {
+ result[sourceId] = items;
+ }
+ }
+
+ return result;
+ }
+ }
+
+ private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ var result = new Dictionary<Guid, Dictionary<Guid, int>>();
+ foreach (var id in sourceIds)
+ {
+ result[id] = [];
+ }
+
+ foreach (var (valueType, weight) in _itemValueDimensions)
+ {
+ var sourceRows = await context.ItemValuesMap.AsNoTracking()
+ .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType)
+ .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet());
+ var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList();
+ if (allKeys.Count == 0)
+ {
+ continue;
+ }
+
+ var candidateRows = await context.ItemValuesMap.AsNoTracking()
+ .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
+ .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
+ ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result);
+ }
+
+ var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking()
+ .Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType))
+ .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType })
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ if (personSourceRows.Count > 0)
+ {
+ var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
+ .Where(m => context.PeopleBaseItemMap
+ .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
+ .Select(s => s.PeopleId)
+ .Contains(m.PeopleId))
+ .Select(m => new { m.ItemId, m.PeopleId })
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ var personToCandidates = personCandidateRows
+ .GroupBy(r => r.PeopleId)
+ .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
+
+ foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!]))
+ {
+ var sourceMap = weightGroup
+ .GroupBy(r => r.ItemId)
+ .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet());
+ ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result);
+ }
+ }
+
+ foreach (var sourceId in sourceIds)
+ {
+ var scoreMap = result[sourceId];
+ scoreMap.Remove(sourceId);
+ if (scoreMap.Count == 0)
+ {
+ result.Remove(sourceId);
+ }
+ }
- return _libraryManager.GetItemList(internalQuery);
+ return result;
+ }
+
+ private static void ApplyDimensionScores<TKey>(
+ List<Guid> sourceIds,
+ Dictionary<Guid, HashSet<TKey>> sourceMap,
+ Dictionary<TKey, List<Guid>> keyToCandidates,
+ int weight,
+ Dictionary<Guid, Dictionary<Guid, int>> result)
+ where TKey : notnull
+ {
+ foreach (var sourceId in sourceIds)
+ {
+ if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
+ {
+ continue;
+ }
+
+ var scoreMap = result[sourceId];
+ foreach (var key in sourceKeys)
+ {
+ if (!keyToCandidates.TryGetValue(key, out var candidates))
+ {
+ continue;
+ }
+
+ foreach (var candidateId in candidates)
+ {
+ scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
+ }
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
index b56779cf3f..358c170db2 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
@@ -8,12 +8,16 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions.Json;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
@@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly IFileSystem _fileSystem;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
private ISimilarItemsProvider[] _similarItemsProviders = [];
/// <summary>
@@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager
/// <param name="appPaths">The server application paths.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="fileSystem">The file system.</param>
+ /// <param name="serverConfigurationManager">The server configuration manager.</param>
public SimilarItemsManager(
ILogger<SimilarItemsManager> logger,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
- IFileSystem fileSystem)
+ IFileSystem fileSystem,
+ IServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_appPaths = appPaths;
_libraryManager = libraryManager;
_fileSystem = fileSystem;
+ _serverConfigurationManager = serverConfigurationManager;
}
/// <inheritdoc/>
@@ -225,6 +233,211 @@ public class SimilarItemsManager : ISimilarItemsManager
.ToList();
}
+ /// <inheritdoc/>
+ public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
+ User? user,
+ Guid parentId,
+ int categoryLimit,
+ int itemLimit,
+ DtoOptions dtoOptions,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(dtoOptions);
+
+ var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = [BaseItemKind.Movie],
+ OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)],
+ Limit = 7,
+ ParentId = parentId,
+ Recursive = true,
+ IsPlayed = true,
+ EnableGroupByMetadataKey = true,
+ DtoOptions = dtoOptions
+ });
+
+ var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
+ if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+ {
+ itemTypes.Add(BaseItemKind.Trailer);
+ itemTypes.Add(BaseItemKind.LiveTvProgram);
+ }
+
+ var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = itemTypes.ToArray(),
+ IsMovie = true,
+ OrderBy = [(ItemSortBy.Random, SortOrder.Descending)],
+ Limit = 10,
+ IsFavoriteOrLiked = true,
+ ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
+ EnableGroupByMetadataKey = true,
+ ParentId = parentId,
+ Recursive = true,
+ DtoOptions = dtoOptions
+ });
+
+ var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
+ var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]);
+ var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]);
+
+ // Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
+ var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
+ ? recentlyPlayedMovies.Take(categoryLimit).ToList()
+ : recentlyPlayedMovies;
+ var likedBaseline = likedMovies.Count > categoryLimit
+ ? likedMovies.Take(categoryLimit).ToList()
+ : likedMovies;
+
+ var batchQuery = new SimilarItemsQuery
+ {
+ User = user,
+ Limit = itemLimit,
+ DtoOptions = dtoOptions
+ };
+
+ var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync(
+ recentlyPlayedBaseline,
+ RecommendationType.SimilarToRecentlyPlayed,
+ batchQuery,
+ cancellationToken).ConfigureAwait(false);
+
+ var similarToLiked = await GetSimilarItemsRecommendationsAsync(
+ likedBaseline,
+ RecommendationType.SimilarToLikedItem,
+ batchQuery,
+ cancellationToken).ConfigureAwait(false);
+
+ var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes);
+ var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes);
+
+ // Use a single enumerator per list, listed twice so MoveNext advances it
+ // twice per round-robin pass (giving these categories double weight).
+ // IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
+ // using var would box separately per list insertion, creating independent copies.
+ IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
+ IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
+
+ var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
+ {
+ similarToRecentlyPlayedEnum,
+ similarToRecentlyPlayedEnum,
+ similarToLikedEnum,
+ similarToLikedEnum,
+ hasDirectorFromRecentlyPlayed.GetEnumerator(),
+ hasActorFromRecentlyPlayed.GetEnumerator()
+ };
+
+ var categories = new List<SimilarItemsRecommendation>();
+ while (categories.Count < categoryLimit)
+ {
+ var allEmpty = true;
+ foreach (var category in categoryTypes)
+ {
+ if (category.MoveNext())
+ {
+ categories.Add(category.Current);
+ allEmpty = false;
+
+ if (categories.Count >= categoryLimit)
+ {
+ break;
+ }
+ }
+ }
+
+ if (allEmpty)
+ {
+ break;
+ }
+ }
+
+ return [.. categories.OrderBy(i => i.RecommendationType)];
+ }
+
+ private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
+ IReadOnlyList<BaseItem> baselineItems,
+ RecommendationType recommendationType,
+ SimilarItemsQuery query,
+ CancellationToken cancellationToken)
+ {
+ var batchProvider = _similarItemsProviders
+ .OfType<IBatchLocalSimilarItemsProvider>()
+ .FirstOrDefault();
+
+ if (batchProvider is null || baselineItems.Count == 0)
+ {
+ return [];
+ }
+
+ var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
+
+ var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
+ foreach (var baseline in baselineItems)
+ {
+ if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
+ {
+ recommendations.Add(new SimilarItemsRecommendation
+ {
+ BaselineItemName = baseline.Name,
+ CategoryId = baseline.Id,
+ RecommendationType = recommendationType,
+ Items = similar
+ });
+ }
+ }
+
+ return recommendations;
+ }
+
+ private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations(
+ User? user,
+ IReadOnlyList<string> names,
+ int itemLimit,
+ DtoOptions dtoOptions,
+ RecommendationType type,
+ IReadOnlyList<BaseItemKind> itemTypes)
+ {
+ var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
+ ? [PersonType.Director]
+ : Array.Empty<string>();
+
+ foreach (var name in names)
+ {
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ Person = name,
+ Limit = itemLimit + 2,
+ PersonTypes = personTypes,
+ IncludeItemTypes = itemTypes.ToArray(),
+ IsMovie = true,
+ IsPlayed = false,
+ EnableGroupByMetadataKey = true,
+ DtoOptions = dtoOptions
+ })
+ .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
+ .Take(itemLimit)
+ .ToList();
+
+ if (items.Count > 0)
+ {
+ yield return new SimilarItemsRecommendation
+ {
+ BaselineItemName = name,
+ CategoryId = name.GetMD5(),
+ RecommendationType = type,
+ Items = items
+ };
+ }
+ }
+ }
+
+ private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
+ {
+ var itemIds = items.Select(i => i.Id).ToArray();
+ return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0);
+ }
+
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
IReadOnlyList<SimilarItemReference> references,
int providerOrder,
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 67b77a112d..ef53e3b326 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -527,42 +527,44 @@ namespace Emby.Server.Implementations.Updates
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-
- // CA5351: Do Not Use Broken Cryptographic Algorithms
+ Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ // CA5351: Do Not Use Broken Cryptographic Algorithms
#pragma warning disable CA5351
- cancellationToken.ThrowIfCancellationRequested();
+ cancellationToken.ThrowIfCancellationRequested();
- var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
- if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogError(
- "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
- package.Name,
- package.Checksum,
- hash);
- throw new InvalidDataException("The checksum of the received data doesn't match.");
- }
+ var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
+ if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogError(
+ "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
+ package.Name,
+ package.Checksum,
+ hash);
+ throw new InvalidDataException("The checksum of the received data doesn't match.");
+ }
- // Version folder as they cannot be overwritten in Windows.
- targetDir += "_" + package.Version;
+ // Version folder as they cannot be overwritten in Windows.
+ targetDir += "_" + package.Version;
- if (Directory.Exists(targetDir))
- {
- try
+ if (Directory.Exists(targetDir))
{
- Directory.Delete(targetDir, true);
- }
+ try
+ {
+ Directory.Delete(targetDir, true);
+ }
#pragma warning disable CA1031 // Do not catch general exception types
- catch
+ catch
#pragma warning restore CA1031 // Do not catch general exception types
- {
- // Ignore any exceptions.
+ {
+ // Ignore any exceptions.
+ }
}
- }
- stream.Position = 0;
- await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken);
+ stream.Position = 0;
+ await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken).ConfigureAwait(false);
+ }
// Ensure we create one or populate existing ones with missing data.
await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index abf27b7702..6a30a80f1d 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -17,6 +17,7 @@ using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -50,6 +51,7 @@ public class LibraryController : BaseJellyfinApiController
private readonly ISimilarItemsManager _similarItemsManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
+ private readonly ICollectionManager _collectionManager;
private readonly IDtoService _dtoService;
private readonly IActivityManager _activityManager;
private readonly ILocalizationManager _localization;
@@ -64,6 +66,7 @@ public class LibraryController : BaseJellyfinApiController
/// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
@@ -75,6 +78,7 @@ public class LibraryController : BaseJellyfinApiController
ISimilarItemsManager similarItemsManager,
ILibraryManager libraryManager,
IUserManager userManager,
+ ICollectionManager collectionManager,
IDtoService dtoService,
IActivityManager activityManager,
ILocalizationManager localization,
@@ -86,6 +90,7 @@ public class LibraryController : BaseJellyfinApiController
_similarItemsManager = similarItemsManager;
_libraryManager = libraryManager;
_userManager = userManager;
+ _collectionManager = collectionManager;
_dtoService = dtoService;
_activityManager = activityManager;
_localization = localization;
@@ -705,6 +710,72 @@ public class LibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Gets the collections that include the specified item.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="startIndex">Optional. The index of the first record in the output.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+ /// <response code="200">Collections returned.</response>
+ /// <response code="401">User context missing.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>The collections that contain the requested item.</returns>
+ [HttpGet("Items/{itemId}/Collections")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<QueryResult<BaseItemDto>> GetItemCollections(
+ [FromRoute, Required] Guid itemId,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields)
+ {
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = userId.IsNullOrEmpty()
+ ? null
+ : _userManager.GetUserById(userId.Value);
+
+ if (user is null)
+ {
+ return Unauthorized();
+ }
+
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
+ var dtoOptions = new DtoOptions { Fields = fields };
+
+ var visibleCollections = _collectionManager
+ .GetCollectionsContainingItem(user, item.Id)
+ .OrderBy(i => i.SortName, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ IEnumerable<BaseItem> pagedCollections = visibleCollections;
+ if (startIndex.HasValue)
+ {
+ pagedCollections = pagedCollections.Skip(startIndex.Value);
+ }
+
+ if (limit.HasValue)
+ {
+ pagedCollections = pagedCollections.Take(limit.Value);
+ }
+
+ var dtos = _dtoService.GetBaseItemDtos(pagedCollections.ToList(), dtoOptions, user);
+
+ return new QueryResult<BaseItemDto>(
+ startIndex,
+ visibleCollections.Count,
+ dtos);
+ }
+
+ /// <summary>
/// Gets similar items.
/// </summary>
/// <param name="itemId">The item id.</param>
diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs
index 50d34d0656..a1f2fe7ce7 100644
--- a/Jellyfin.Api/Controllers/MoviesController.cs
+++ b/Jellyfin.Api/Controllers/MoviesController.cs
@@ -1,17 +1,13 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
-using Jellyfin.Data.Enums;
-using Jellyfin.Database.Implementations.Entities;
-using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@@ -30,27 +26,23 @@ namespace Jellyfin.Api.Controllers;
public class MoviesController : BaseJellyfinApiController
{
private readonly IUserManager _userManager;
- private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
- private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly ISimilarItemsManager _similarItemsManager;
/// <summary>
/// Initializes a new instance of the <see cref="MoviesController"/> class.
/// </summary>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param>
public MoviesController(
IUserManager userManager,
- ILibraryManager libraryManager,
IDtoService dtoService,
- IServerConfigurationManager serverConfigurationManager)
+ ISimilarItemsManager similarItemsManager)
{
_userManager = userManager;
- _libraryManager = libraryManager;
_dtoService = dtoService;
- _serverConfigurationManager = serverConfigurationManager;
+ _similarItemsManager = similarItemsManager;
}
/// <summary>
@@ -61,15 +53,17 @@ public class MoviesController : BaseJellyfinApiController
/// <param name="fields">Optional. The fields to return.</param>
/// <param name="categoryLimit">The max number of categories to return.</param>
/// <param name="itemLimit">The max number of items to return per category.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
/// <response code="200">Movie recommendations returned.</response>
/// <returns>The list of movie recommendations.</returns>
[HttpGet("Recommendations")]
- public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
+ public async Task<ActionResult<IEnumerable<RecommendationDto>>> GetMovieRecommendations(
[FromQuery] Guid? userId,
[FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
[FromQuery] int categoryLimit = 5,
- [FromQuery] int itemLimit = 8)
+ [FromQuery] int itemLimit = 8,
+ CancellationToken cancellationToken = default)
{
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
@@ -77,251 +71,16 @@ public class MoviesController : BaseJellyfinApiController
: _userManager.GetUserById(userId.Value);
var dtoOptions = new DtoOptions { Fields = fields };
- var categories = new List<RecommendationDto>();
+ var recommendations = await _similarItemsManager
+ .GetMovieRecommendationsAsync(user, parentId ?? Guid.Empty, categoryLimit, itemLimit, dtoOptions, cancellationToken)
+ .ConfigureAwait(false);
- var parentIdGuid = parentId ?? Guid.Empty;
-
- var query = new InternalItemsQuery(user)
- {
- IncludeItemTypes = new[]
- {
- BaseItemKind.Movie,
- // nameof(Trailer),
- // nameof(LiveTvProgram)
- },
- // IsMovie = true
- OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) },
- Limit = 7,
- ParentId = parentIdGuid,
- Recursive = true,
- IsPlayed = true,
- DtoOptions = dtoOptions
- };
-
- var recentlyPlayedMovies = _libraryManager.GetItemList(query);
-
- var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
- if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
- {
- itemTypes.Add(BaseItemKind.Trailer);
- itemTypes.Add(BaseItemKind.LiveTvProgram);
- }
-
- var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
- {
- IncludeItemTypes = itemTypes.ToArray(),
- IsMovie = true,
- OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
- Limit = 10,
- IsFavoriteOrLiked = true,
- ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
- EnableGroupByMetadataKey = true,
- ParentId = parentIdGuid,
- Recursive = true,
- DtoOptions = dtoOptions
- });
-
- var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
- // Get recently played directors
- var recentDirectors = GetDirectors(mostRecentMovies)
- .ToList();
-
- // Get recently played actors
- var recentActors = GetActors(mostRecentMovies)
- .ToList();
-
- var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
- var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
-
- var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
- var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
-
- var categoryTypes = new List<IEnumerator<RecommendationDto>>
- {
- // Give this extra weight
- similarToRecentlyPlayed,
- similarToRecentlyPlayed,
-
- // Give this extra weight
- similarToLiked,
- similarToLiked,
- hasDirectorFromRecentlyPlayed,
- hasActorFromRecentlyPlayed
- };
-
- while (categories.Count < categoryLimit)
+ return Ok(recommendations.Select(r => new RecommendationDto
{
- var allEmpty = true;
-
- foreach (var category in categoryTypes)
- {
- if (category.MoveNext())
- {
- categories.Add(category.Current);
- allEmpty = false;
-
- if (categories.Count >= categoryLimit)
- {
- break;
- }
- }
- }
-
- if (allEmpty)
- {
- break;
- }
- }
-
- return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable());
- }
-
- private IEnumerable<RecommendationDto> GetWithDirector(
- User? user,
- IEnumerable<string> names,
- int itemLimit,
- DtoOptions dtoOptions,
- RecommendationType type)
- {
- var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
- if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
- {
- itemTypes.Add(BaseItemKind.Trailer);
- itemTypes.Add(BaseItemKind.LiveTvProgram);
- }
-
- foreach (var name in names)
- {
- var items = _libraryManager.GetItemList(
- new InternalItemsQuery(user)
- {
- Person = name,
- // Account for duplicates by IMDb id, since the database doesn't support this yet
- Limit = itemLimit + 2,
- PersonTypes = new[] { PersonType.Director },
- IncludeItemTypes = itemTypes.ToArray(),
- IsMovie = true,
- EnableGroupByMetadataKey = true,
- DtoOptions = dtoOptions
- }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
- .Take(itemLimit)
- .ToList();
-
- if (items.Count > 0)
- {
- var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
-
- yield return new RecommendationDto
- {
- BaselineItemName = name,
- CategoryId = name.GetMD5(),
- RecommendationType = type,
- Items = returnItems
- };
- }
- }
- }
-
- private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
- {
- var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
- if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
- {
- itemTypes.Add(BaseItemKind.Trailer);
- itemTypes.Add(BaseItemKind.LiveTvProgram);
- }
-
- foreach (var name in names)
- {
- var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
- {
- Person = name,
- // Account for duplicates by IMDb id, since the database doesn't support this yet
- Limit = itemLimit + 2,
- IncludeItemTypes = itemTypes.ToArray(),
- IsMovie = true,
- EnableGroupByMetadataKey = true,
- DtoOptions = dtoOptions
- }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
- .Take(itemLimit)
- .ToList();
-
- if (items.Count > 0)
- {
- var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
-
- yield return new RecommendationDto
- {
- BaselineItemName = name,
- CategoryId = name.GetMD5(),
- RecommendationType = type,
- Items = returnItems
- };
- }
- }
- }
-
- private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
- {
- var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
- if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
- {
- itemTypes.Add(BaseItemKind.Trailer);
- itemTypes.Add(BaseItemKind.LiveTvProgram);
- }
-
- foreach (var item in baselineItems)
- {
- var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
- {
- Limit = itemLimit,
- IncludeItemTypes = itemTypes.ToArray(),
- IsMovie = true,
- EnableGroupByMetadataKey = true,
- DtoOptions = dtoOptions
- });
-
- if (similar.Count > 0)
- {
- var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
-
- yield return new RecommendationDto
- {
- BaselineItemName = item.Name,
- CategoryId = item.Id,
- RecommendationType = type,
- Items = returnItems
- };
- }
- }
- }
-
- private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
- {
- var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director })
- {
- MaxListOrder = 3
- });
-
- var itemIds = items.Select(i => i.Id).ToList();
-
- return people
- .Where(i => itemIds.Contains(i.ItemId))
- .Select(i => i.Name)
- .DistinctNames();
- }
-
- private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
- {
- var people = _libraryManager.GetPeople(new InternalPeopleQuery(
- new[] { PersonType.Director },
- Array.Empty<string>()));
-
- var itemIds = items.Select(i => i.Id).ToList();
-
- return people
- .Where(i => itemIds.Contains(i.ItemId))
- .Select(i => i.Name)
- .DistinctNames();
+ BaselineItemName = r.BaselineItemName,
+ CategoryId = r.CategoryId,
+ RecommendationType = r.RecommendationType,
+ Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user)
+ }));
}
}
diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
index 9e11b6be62..5e5ce320a5 100644
--- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
+++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
@@ -91,14 +91,25 @@ public class LinkedChildrenService : ILinkedChildrenService
}
/// <inheritdoc/>
- public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId)
+ public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null)
{
using var context = _dbProvider.CreateDbContext();
- return context.LinkedChildren
- .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual)
- .Select(lc => lc.ParentId)
- .Distinct()
- .ToList();
+
+ var query = context.LinkedChildren
+ .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual);
+
+ if (parentType.HasValue)
+ {
+ var parentTypeName = _itemTypeLookup.BaseItemKindNames[parentType.Value];
+ query = query.Join(
+ context.BaseItems
+ .Where(item => item.Type == parentTypeName),
+ lc => lc.ParentId,
+ item => item.Id,
+ (lc, _) => lc);
+ }
+
+ return query.Select(lc => lc.ParentId).Distinct().ToList();
}
/// <inheritdoc/>
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index b612112d49..6062aaca2f 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -165,6 +165,31 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
transaction.Commit();
}
+ /// <inheritdoc/>
+ public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
+ {
+ using var context = _dbProvider.CreateDbContext();
+ var query = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Where(m => itemIds.Contains(m.ItemId));
+
+ if (personTypes.Count > 0)
+ {
+ query = query.Where(m => personTypes.Contains(m.People.PersonType));
+ }
+
+ var names = query
+ .Select(m => m.People.Name)
+ .Distinct();
+
+ if (limit > 0)
+ {
+ names = names.Take(limit);
+ }
+
+ return names.ToArray();
+ }
+
private PersonInfo Map(People people)
{
var mapping = people.BaseItems?.FirstOrDefault();
@@ -239,7 +264,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value);
+ query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value));
}
if (!string.IsNullOrWhiteSpace(filter.NameContains))
diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs
index 206b5ac426..8d5d54ffd9 100644
--- a/MediaBrowser.Controller/Collections/ICollectionManager.cs
+++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs
@@ -58,6 +58,14 @@ namespace MediaBrowser.Controller.Collections
IEnumerable<BaseItem> CollapseItemsWithinBoxSets(IEnumerable<BaseItem> items, User user);
/// <summary>
+ /// Gets the collections accessible to the supplied user that contain the provided item.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="itemId">The item identifier.</param>
+ /// <returns>The collections containing the item.</returns>
+ IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId);
+
+ /// <summary>
/// Gets the folder where collections are stored.
/// </summary>
/// <param name="createIfNeeded">Will create the collection folder on the storage if set to true.</param>
diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs
new file mode 100644
index 0000000000..af49711606
--- /dev/null
+++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// A local similar items provider that supports batch queries across multiple source items.
+/// Implementations share access filtering and entity loading across all sources for better performance.
+/// </summary>
+public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider
+{
+ /// <summary>
+ /// Gets similar items for multiple source items in a single batch.
+ /// </summary>
+ /// <param name="sourceItems">The source items to find similar items for.</param>
+ /// <param name="query">The query options.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Per-source-item results keyed by source item ID.</returns>
+ Task<Dictionary<Guid, IReadOnlyList<BaseItem>>> GetBatchSimilarItemsAsync(
+ IReadOnlyList<BaseItem> sourceItems,
+ SimilarItemsQuery query,
+ CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index f4c2196400..c23eba75ef 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -598,6 +598,15 @@ namespace MediaBrowser.Controller.Library
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query);
/// <summary>
+ /// Gets distinct people names for multiple items.
+ /// </summary>
+ /// <param name="itemIds">The item IDs.</param>
+ /// <param name="personTypes">The person types to include.</param>
+ /// <param name="limit">Maximum number of names.</param>
+ /// <returns>The distinct people names.</returns>
+ IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit);
+
+ /// <summary>
/// Queries the items.
/// </summary>
/// <param name="query">The query.</param>
diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
index 0ced6f71ee..36fa547eeb 100644
--- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
+++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
@@ -6,6 +6,7 @@ using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Library;
@@ -47,4 +48,23 @@ public interface ISimilarItemsManager
int? limit,
LibraryOptions? libraryOptions,
CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Builds movie recommendations for a user: a mix of similar-items and person-based categories,
+ /// scheduled round-robin and capped to <paramref name="categoryLimit"/>.
+ /// </summary>
+ /// <param name="user">The user the recommendations are for. May be <see langword="null"/> for anonymous access.</param>
+ /// <param name="parentId">The library/folder to localize the search to. Pass <see cref="Guid.Empty"/> to use the root.</param>
+ /// <param name="categoryLimit">Maximum number of recommendation categories to return.</param>
+ /// <param name="itemLimit">Maximum number of items per category.</param>
+ /// <param name="dtoOptions">DTO options used when querying the library.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The list of recommendation categories, ordered by <see cref="RecommendationType"/>.</returns>
+ Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
+ User? user,
+ Guid parentId,
+ int categoryLimit,
+ int itemLimit,
+ DtoOptions dtoOptions,
+ CancellationToken cancellationToken);
}
diff --git a/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs
new file mode 100644
index 0000000000..71346fcadf
--- /dev/null
+++ b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Dto;
+
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// A recommendation category derived from a baseline item, holding similar items prior to DTO conversion.
+/// </summary>
+public sealed class SimilarItemsRecommendation
+{
+ /// <summary>
+ /// Gets the display name of the baseline item the recommendation is based on.
+ /// </summary>
+ public required string BaselineItemName { get; init; }
+
+ /// <summary>
+ /// Gets an identifier for the recommendation category.
+ /// </summary>
+ public required Guid CategoryId { get; init; }
+
+ /// <summary>
+ /// Gets the recommendation type.
+ /// </summary>
+ public required RecommendationType RecommendationType { get; init; }
+
+ /// <summary>
+ /// Gets the similar items for the baseline, ordered by relevance.
+ /// </summary>
+ public required IReadOnlyList<BaseItem> Items { get; init; }
+}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 8688ea4b6c..ff8d84d45e 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -86,6 +86,7 @@ namespace MediaBrowser.Controller.MediaEncoding
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0);
+ private readonly Version _minFFmpegNoiseBsfDrop = new Version(5, 0);
private static readonly string[] _videoProfilesH264 =
[
@@ -1547,20 +1548,61 @@ namespace MediaBrowser.Controller.MediaEncoding
public string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer)
{
- var bitStreamArgs = string.Empty;
+ var filters = new List<string>();
+
+ var noiseFilter = GetCopiedAudioTrimBsf(state);
+ if (!string.IsNullOrEmpty(noiseFilter))
+ {
+ filters.Add(noiseFilter);
+ }
+
var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.');
// Apply aac_adtstoasc bitstream filter when media source is in mpegts.
if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(mediaSourceContainer, "ts", StringComparison.OrdinalIgnoreCase)
|| string.Equals(mediaSourceContainer, "aac", StringComparison.OrdinalIgnoreCase)
- || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)))
+ || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase))
+ && IsAAC(state.AudioStream))
{
- bitStreamArgs = GetBitStreamArgs(state, MediaStreamType.Audio);
- bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs;
+ filters.Add("aac_adtstoasc");
}
- return bitStreamArgs;
+ return filters.Count == 0
+ ? string.Empty
+ : " -bsf:a " + string.Join(',', filters);
+ }
+
+ // When video is transcoded, accurate_seek (the default) trims video to the
+ // exact seek point via decoder-side frame discard. But stream-copied audio
+ // bypasses the decoder, so it starts from the nearest keyframe — potentially
+ // seconds before the target. Use the noise bsf to drop copied audio packets
+ // before the seek target, achieving the same trim precision without
+ // re-encoding. The noise bsf's drop= parameter requires ffmpeg >= 5.0.
+ // Important: make sure not to use it with wtv because it breaks seeking
+ private string GetCopiedAudioTrimBsf(EncodingJobInfo state)
+ {
+ if (state.TranscodingType is not TranscodingJobType.Hls
+ || !state.IsVideoRequest
+ || IsCopyCodec(state.OutputVideoCodec)
+ || !IsCopyCodec(state.OutputAudioCodec)
+ || string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase)
+ || _mediaEncoder.EncoderVersion < _minFFmpegNoiseBsfDrop)
+ {
+ return null;
+ }
+
+ var startTicks = state.BaseRequest.StartTimeTicks ?? 0;
+ if (startTicks <= 0)
+ {
+ return null;
+ }
+
+ var seekSeconds = startTicks / (double)TimeSpan.TicksPerSecond;
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "noise=drop='lt(pts*tb\\,{0:F3})'",
+ seekSeconds);
}
public static string GetSegmentFileExtension(string segmentContainer)
@@ -3006,23 +3048,6 @@ namespace MediaBrowser.Controller.MediaEncoding
}
seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick));
-
- if (state.IsVideoRequest)
- {
- // If we are remuxing, then the copied stream cannot be seeked accurately (it will seek to the nearest
- // keyframe). If we are using fMP4, then force all other streams to use the same inaccurate seeking to
- // avoid A/V sync issues which cause playback issues on some devices.
- // When remuxing video, the segment start times correspond to key frames in the source stream, so this
- // option shouldn't change the seeked point that much.
- // Important: make sure not to use it with wtv because it breaks seeking
- if (state.TranscodingType is TranscodingJobType.Hls
- && string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)
- && (IsCopyCodec(state.OutputVideoCodec) || IsCopyCodec(state.OutputAudioCodec))
- && !string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase))
- {
- seekParam += " -noaccurate_seek";
- }
- }
}
return seekParam;
diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
index d0cddf54a6..a4614fc125 100644
--- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
+++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.Audio;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
@@ -29,8 +30,9 @@ public interface ILinkedChildrenService
/// Gets parent IDs that reference the specified child with LinkedChildType.Manual.
/// </summary>
/// <param name="childId">The child item ID.</param>
+ /// <param name="parentType">Optional parent item type filter.</param>
/// <returns>List of parent IDs that reference the child.</returns>
- IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId);
+ IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null);
/// <summary>
/// Updates LinkedChildren references from one child to another.
diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
index a89f3ef9ee..7474130ec4 100644
--- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
@@ -32,4 +32,13 @@ public interface IPeopleRepository
/// <param name="filter">The query.</param>
/// <returns>The list of people names matching the filter.</returns>
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter);
+
+ /// <summary>
+ /// Gets distinct people names for multiple items efficiently by querying from the mapping table.
+ /// </summary>
+ /// <param name="itemIds">The item IDs to get people for.</param>
+ /// <param name="personTypes">The person types to include (e.g. "Actor", "Director").</param>
+ /// <param name="limit">Maximum number of names to return.</param>
+ /// <returns>The distinct people names.</returns>
+ IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit);
}
diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs
index 98fc2e632f..f5bb5330ed 100644
--- a/MediaBrowser.Model/Configuration/EncodingOptions.cs
+++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs
@@ -61,7 +61,7 @@ public class EncodingOptions
SubtitleExtractionTimeoutMinutes = 30;
AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = ["mkv"];
HardwareDecodingCodecs = ["h264", "vc1"];
- HlsAudioSeekStrategy = HlsAudioSeekStrategy.DisableAccurateSeek;
+ HlsAudioSeekStrategy = HlsAudioSeekStrategy.TrimCopiedAudio;
}
/// <summary>
@@ -307,6 +307,6 @@ public class EncodingOptions
/// <summary>
/// Gets or sets the method used for audio seeking in HLS.
/// </summary>
- [DefaultValue(HlsAudioSeekStrategy.DisableAccurateSeek)]
+ [DefaultValue(HlsAudioSeekStrategy.TrimCopiedAudio)]
public HlsAudioSeekStrategy HlsAudioSeekStrategy { get; set; }
}
diff --git a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs
index 49feeb435f..c9155faeb1 100644
--- a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs
+++ b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs
@@ -7,11 +7,12 @@ namespace MediaBrowser.Model.Configuration
public enum HlsAudioSeekStrategy
{
/// <summary>
- /// If the video stream is transcoded and the audio stream is copied,
- /// seek the video stream to the same keyframe as the audio stream. The
- /// resulting timestamps in the output streams may be inaccurate.
+ /// When video is transcoded and audio is copied, use a bitstream filter
+ /// to drop copied audio packets before the seek point, aligning them
+ /// with the accurately-seeked video. Timestamps are accurate and audio
+ /// remains stream-copied (no re-encoding overhead).
/// </summary>
- DisableAccurateSeek = 0,
+ TrimCopiedAudio = 0,
/// <summary>
/// Prevent audio streams from being copied if the video stream is transcoded.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs
index b2bcbf2bb6..34810b9199 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs
@@ -108,5 +108,50 @@ public enum ViewType
/// <summary>
/// Shows upcoming.
/// </summary>
- Upcoming = 20
+ Upcoming = 20,
+
+ /// <summary>
+ /// Shows authors.
+ /// </summary>
+ Authors = 21,
+
+ /// <summary>
+ /// Shows books.
+ /// </summary>
+ Books = 22,
+
+ /// <summary>
+ /// Shows folders.
+ /// </summary>
+ Folders = 23,
+
+ /// <summary>
+ /// Shows mixed media.
+ /// </summary>
+ Mixed = 24,
+
+ /// <summary>
+ /// Shows photos.
+ /// </summary>
+ Photos = 25,
+
+ /// <summary>
+ /// Shows photo albums.
+ /// </summary>
+ PhotoAlbums = 26,
+
+ /// <summary>
+ /// Shows series timers.
+ /// </summary>
+ SeriesTimers = 27,
+
+ /// <summary>
+ /// Shows studios.
+ /// </summary>
+ Studios = 28,
+
+ /// <summary>
+ /// Shows videos.
+ /// </summary>
+ Videos = 29
}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs
new file mode 100644
index 0000000000..2dcb898051
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Globalization;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Configuration;
+using Moq;
+using Xunit;
+using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding
+{
+ public class EncodingHelperAudioBitStreamTests
+ {
+ private const string BothFilters = " -bsf:a noise=drop='lt(pts*tb\\,63.063)',aac_adtstoasc";
+ private const string NoiseOnly = " -bsf:a noise=drop='lt(pts*tb\\,63.063)'";
+ private const string AdtsOnly = " -bsf:a aac_adtstoasc";
+ private const long DefaultSeekTicks = 630_630_000L;
+ private const string DefaultFfmpegVersion = "5.0";
+
+ private static EncodingHelper CreateHelper(string ffmpegVersion)
+ {
+ var mediaEncoder = new Mock<IMediaEncoder>();
+ mediaEncoder
+ .Setup(e => e.GetTimeParameter(It.IsAny<long>()))
+ .Returns((long ticks) => TimeSpan.FromTicks(ticks).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture));
+ mediaEncoder
+ .SetupGet(e => e.EncoderVersion)
+ .Returns(Version.Parse(ffmpegVersion));
+
+ return new EncodingHelper(
+ Mock.Of<IApplicationPaths>(),
+ mediaEncoder.Object,
+ Mock.Of<ISubtitleEncoder>(),
+ Mock.Of<IConfiguration>(),
+ Mock.Of<IConfigurationManager>(),
+ Mock.Of<IPathManager>());
+ }
+
+ private static EncodingJobInfo CreateState(
+ TranscodingJobType jobType,
+ string outputVideoCodec,
+ string outputAudioCodec,
+ string audioStreamCodec,
+ string inputContainer,
+ long startTimeTicks)
+ {
+ return new EncodingJobInfo(jobType)
+ {
+ IsVideoRequest = true,
+ OutputVideoCodec = outputVideoCodec,
+ OutputAudioCodec = outputAudioCodec,
+ InputContainer = inputContainer,
+ RunTimeTicks = TimeSpan.FromMinutes(10).Ticks,
+ AudioStream = new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ Codec = audioStreamCodec
+ },
+ BaseRequest = new BaseEncodingJobOptions
+ {
+ StartTimeTicks = startTimeTicks
+ }
+ };
+ }
+
+ [Theory]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", BothFilters)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "aac", BothFilters)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "hls", BothFilters)]
+ [InlineData(TranscodingJobType.Progressive, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "copy", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "aac", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "wtv", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", 0L, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, "4.4.6", "mp4", "ts", AdtsOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "ts", "ts", NoiseOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "mkv", NoiseOnly)]
+ [InlineData(TranscodingJobType.Hls, "libx264", "copy", "ac3", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", NoiseOnly)]
+ public void AudioBitStreamArguments_AppliesGates(
+ TranscodingJobType jobType,
+ string outputVideoCodec,
+ string outputAudioCodec,
+ string audioStreamCodec,
+ string inputContainer,
+ long startTicks,
+ string ffmpegVersion,
+ string segmentContainer,
+ string mediaSourceContainer,
+ string expected)
+ {
+ var state = CreateState(jobType, outputVideoCodec, outputAudioCodec, audioStreamCodec, inputContainer, startTicks);
+ var result = CreateHelper(ffmpegVersion).GetAudioBitStreamArguments(state, segmentContainer, mediaSourceContainer);
+ Assert.Equal(expected, result);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs
index edbb46b34c..b9b2862c65 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs
@@ -23,6 +23,7 @@ public sealed class LibraryControllerTests : IClassFixture<JellyfinApplicationFa
[InlineData("Items/{0}/ThemeMedia")]
[InlineData("Items/{0}/Ancestors")]
[InlineData("Items/{0}/Download")]
+ [InlineData("Items/{0}/Collections")]
[InlineData("Artists/{0}/Similar")]
[InlineData("Items/{0}/Similar")]
[InlineData("Albums/{0}/Similar")]