Merge remote-tracking branch 'upstream/master' into search-rebased

# Conflicts:
#	Emby.Server.Implementations/Library/LibraryManager.cs
#	Jellyfin.Server.Implementations/Item/PeopleRepository.cs
#	MediaBrowser.Controller/Library/ILibraryManager.cs
#	MediaBrowser.Controller/Persistence/IPeopleRepository.cs
This commit is contained in:
Shadowghost
2026-05-30 19:07:18 +02:00
56 changed files with 5203 additions and 558 deletions

View File

@@ -87,6 +87,7 @@ body:
label: Jellyfin Server version
description: What version of Jellyfin are you using?
options:
- 10.11.10
- 10.11.9
- 10.11.8
- 10.11.7

View File

@@ -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'

View File

@@ -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'

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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'

View File

@@ -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.'

4
.gitignore vendored
View File

@@ -278,3 +278,7 @@ apiclient/generated
# Omnisharp crash logs
mono_crash.*.json
# Devcontainer temp files
.devcontainer/devcontainer-lock.json
dotnet/

View File

@@ -18,7 +18,7 @@
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<PackageVersion Include="Ignore" Version="0.2.1" />
@@ -47,7 +47,7 @@
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
<PackageVersion Include="Moq" Version="4.18.4" />
@@ -68,10 +68,9 @@
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
<PackageVersion Include="SkiaSharp" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
@@ -79,7 +78,7 @@
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.13.0" />
<PackageVersion Include="z440.atl.core" Version="7.14.0" />
<PackageVersion Include="TMDbLib" Version="3.0.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />

View File

@@ -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();

View File

@@ -3395,9 +3395,9 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItem(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
{
return _peopleRepository.GetPeopleNamesByItem(itemIds, personTypes);
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit);
}
public void UpdatePeople(BaseItem item, List<PersonInfo> people)

View File

@@ -440,10 +440,6 @@ namespace Emby.Server.Implementations.Library
if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase))
{
originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage)
? originalLanguage.Split(',').FirstOrDefault()
: null;
if (user.PlayDefaultAudioTrack)
{
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(
@@ -498,17 +494,7 @@ namespace Emby.Server.Implementations.Library
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
var originalLanguage = item?.OriginalLanguage ?? item switch
{
Episode episode => episode.Series.OriginalLanguage,
Video video => video.GetOwner() switch
{
Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage,
BaseItem owner => owner.OriginalLanguage,
null => null
},
_ => null
};
var originalLanguage = item?.GetInheritedOriginalLanguage();
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage);
SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);

View File

@@ -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)
{
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)]
};
var limit = query.Limit ?? 50;
var dtoOptions = query.DtoOptions ?? new DtoOptions();
return _libraryManager.GetItemList(internalQuery);
if (sourceItems.Count > MaxBatchSourceItems)
{
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 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;
}
}
}
}
}

View File

@@ -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,

View File

@@ -108,5 +108,5 @@
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
"Original": "Original",
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}."
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}"
}

View File

@@ -16,5 +16,97 @@
"HeaderLiveTV": "טלוויזיה בשידור חי",
"HeaderNextUp": "הבא",
"HearingImpaired": "ללקויי שמיעה",
"HomeVideos": "סרטונים ביתיים"
"HomeVideos": "סרטונים ביתיים",
"AppDeviceValues": "אפליקציה: {0}, מכשיר: {1}",
"AuthenticationSucceededWithUserName": "{0} אומת בהצלחה",
"Default": "בררת מחדל",
"FailedLoginAttemptWithUserName": "התחברות נכשלה מ {0}",
"Forced": "בכוח",
"Inherit": "ירש",
"LabelIpAddressValue": "כתובת IP: {0}",
"LabelRunningTimeValue": "זמן ריצה: {0}",
"Latest": "הכי חדש",
"LyricDownloadFailureFromForItem": "מילות שיר נכשלו לרדת מ{0} בשביל {1}",
"MixedContent": "תוכן מעורב",
"MusicVideos": "סרטוני מוזיקה",
"NameInstallFailed": "{0} התכנות כושלות",
"NameSeasonUnknown": "עונה לא ידוע",
"NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.",
"NotificationOptionApplicationUpdateAvailable": "גרסת אפליקציה חדשה זמינה להורדה",
"NotificationOptionApplicationUpdateInstalled": "עדכון אפליקציה הותקן",
"NotificationOptionAudioPlayback": "החלה השמעת אודיו",
"NotificationOptionAudioPlaybackStopped": "ניגון השמע הופסק",
"NotificationOptionCameraImageUploaded": "תמונת מצלמה עודכן",
"NotificationOptionInstallationFailed": "התקנה נכשלה",
"NotificationOptionNewLibraryContent": "תוכן חדש נוסף",
"NotificationOptionPluginError": "תוסף נכשל",
"NotificationOptionPluginInstalled": "תוסף הותקן",
"NotificationOptionPluginUninstalled": "תוסף נמחק",
"NotificationOptionPluginUpdateInstalled": "עידכון לתוסף הותקן",
"NotificationOptionServerRestartRequired": "נדרש התחול מחדש לשרת",
"NotificationOptionTaskFailed": "כשל במשימה מתוכננת",
"NotificationOptionUserLockedOut": "המשתמש ננעל",
"NotificationOptionVideoPlayback": "החלה הפעלת וידאו",
"NotificationOptionVideoPlaybackStopped": "הפעלת הסרטון הופסקה",
"Original": "מקורי",
"Photos": "תמונות",
"PluginInstalledWithName": "{0} הותקן",
"PluginUninstalledWithName": "{0} נמחק",
"PluginUpdatedWithName": "{0} עודכן",
"ScheduledTaskFailedWithName": "{0} נכשל",
"Shows": "סדרות",
"StartupEmbyServerIsLoading": "שרת Jellyfin נטען. אנא נסה שוב בקרוב.",
"SubtitleDownloadFailureFromForItem": "הורדת הכתוביות מ-{0} עבור {1} נכשלה",
"TvShows": "תוכניות טלויזיה",
"Undefined": "לא מוגדר",
"UserCreatedWithName": "המשתמש {0} נוצר",
"UserDeletedWithName": "המשתמש {0} נמחק",
"UserDownloadingItemWithValues": "{0} מוריד את {1}",
"UserLockedOutWithName": "המשתמש {0} ננעל בחוץ",
"UserOfflineFromDevice": "{0} התנתק מ-{1}",
"UserOnlineFromDevice": "{0} מחובר מ-{1}",
"UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}",
"UserStartedPlayingItemWithValues": "{0} מנגן ב-{1} ב-{2}",
"UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} ב-{2}",
"VersionNumber": "גרסה {0}",
"TasksMaintenanceCategory": "תחזוקה",
"TasksLibraryCategory": "ספריה",
"TasksApplicationCategory": "אפליקציה",
"TasksChannelsCategory": "ערוצי אינטרנט",
"TaskCleanActivityLog": "נקה יומן פעילות",
"TaskCleanActivityLogDescription": "מוחק רשומות יומן פעילות ישנות יותר מהגיל שהוגדר.",
"TaskCleanCache": "נקה ספריית מטמון",
"TaskCleanCacheDescription": "מוחק קבצי מטמון שאינם נחוצים עוד על ידי המערכת.",
"TaskRefreshChapterImages": "חלץ תמונות פרק",
"TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות עבור סרטונים שיש להם פרקים.",
"TaskAudioNormalization": "נורמליזציה של שמע",
"TaskAudioNormalizationDescription": "סורק קבצים לאיתור נתוני נרמול שמע.",
"TaskRefreshLibrary": "סרוק ספריית מדיה",
"TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך לאיתור קבצים חדשים ומרענן מטא-דאטה.",
"TaskCleanLogs": "נקה ספריית יומן",
"TaskCleanLogsDescription": "מוחק קבצי יומן שגילם עולה על {0} ימים.",
"TaskRefreshPeople": "רענן אנשים",
"TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.",
"TaskRefreshTrickplayImages": "צור תמונות Trickplay",
"TaskRefreshTrickplayImagesDescription": "יוצר תצוגות מקדימות של trickplay עבור סרטונים בספריות מופעלות.",
"TaskUpdatePlugins": "עדכן פלאגינים",
"TaskUpdatePluginsDescription": "מוריד ומתקין עדכונים עבור תוספים שתצורתם נקבעה לעדכון אוטומטי.",
"TaskCleanTranscode": "נקה ספריית קידוד",
"TaskCleanTranscodeDescription": "תמחוק את קבצי הקידוד בני יותר מיום.",
"TaskRefreshChannels": "רענן ערוצים",
"TaskRefreshChannelsDescription": "מרענן את פרטי ערוץ האינטרנט.",
"TaskDownloadMissingLyrics": "הורד מילות שיר חסרות",
"TaskDownloadMissingLyricsDescription": "הורדות מילים לשירים",
"TaskDownloadMissingSubtitles": "הורד כתוביות חסרות",
"TaskDownloadMissingSubtitlesDescription": "מחפש באינטרנט אחר כתוביות חסרות בהתבסס על תצורת מטא-דאטה.",
"TaskOptimizeDatabase": "בצע אופטימיזציה של מסד הנתונים",
"TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים וקיצוץ שטח פנוי. הפעלת משימה זו לאחר סריקת הספרייה או ביצוע שינויים אחרים שמשמעותם שינויים בבסיס הנתונים עשויה לשפר את הביצועים.",
"TaskKeyframeExtractor": "מחלץ פריים מרכזי",
"TaskKeyframeExtractorDescription": "מחלץ פריימים מרכזיים מקבצי וידאו כדי ליצור רשימות השמעה HLS מדויקות יותר. משימה זו עשויה להימשך זמן רב.",
"TaskExtractMediaSegments": "סריקת מקטעי מדיה",
"TaskExtractMediaSegmentsDescription": "מחלץ או משיג קטעי מדיה מתוספים התומכים ב-MediaSegment.",
"TaskMoveTrickplayImages": "העברת מיקום תמונת Trickplay",
"TaskMoveTrickplayImagesDescription": "מעביר קבצי trickplay קיימים בהתאם להגדרות הספרייה.",
"CleanupUserDataTask": "משימת ניקוי נתוני משתמש",
"CleanupUserDataTaskDescription": "מנקה את כל נתוני המשתמש (מצב צפייה, סטטוס מועדף וכו') ממדיה שכבר לא הייתה קיימת במשך 90 יום לפחות."
}

View File

@@ -20,7 +20,7 @@
"External": "გარე",
"HeaderFavoriteEpisodes": "რჩეული ეპიზოდები",
"HearingImpaired": "სმენადაქვეითებული",
"LabelRunningTimeValue": "ხანგრძლივობა: {0}",
"LabelRunningTimeValue": "გაშვების დრო: {0}",
"MixedContent": "შერეული შემცველობა",
"MusicVideos": "მუსიკის ვიდეოები",
"NotificationOptionInstallationFailed": "დაყენების შეცდომა",
@@ -31,7 +31,7 @@
"PluginUninstalledWithName": "{0} წაიშალა",
"VersionNumber": "ვერსია {0}",
"TasksChannelsCategory": "ინტერნეტ-არხები",
"TaskRefreshChannelsDescription": "ინტერნეტ-არხის ინფორმაციის განახლება.",
"TaskRefreshChannelsDescription": "განაახლებს ინტერნეტ-არხის ინფორმაცია.",
"Collections": "კოლექციები",
"Default": "ნაგულისხმევი",
"Favorites": "რჩეულები",
@@ -53,32 +53,32 @@
"TaskOptimizeDatabase": "მონაცემთა ბაზის ოპტიმიზაცია",
"TaskKeyframeExtractor": "საკვანძო კადრის გამომღები",
"LabelIpAddressValue": "IP მისამართი: {0}",
"NameInstallFailed": "{0}-ის დაყენების შეცდომა",
"NameInstallFailed": "{0}-ის დაყენების ჩავარდა",
"NotificationOptionApplicationUpdateAvailable": "ხელმისაწვდომია აპლიკაციის განახლება",
"NotificationOptionAudioPlaybackStopped": "აუდიოს დაკვრა გაჩერებულია",
"NotificationOptionNewLibraryContent": "ახალი შემცველობა დამატებულია",
"NotificationOptionPluginUpdateInstalled": "მოდულიs განახლება დაყენებულია",
"NotificationOptionPluginUpdateInstalled": "დამატების განახლება დაყენებულია",
"NotificationOptionServerRestartRequired": "საჭიროა სერვერის გადატვირთვა",
"NotificationOptionTaskFailed": "გეგმიური დავალების შეცდომა",
"NotificationOptionTaskFailed": "დაგეგმილი ამოცანა ჩავარდა",
"NotificationOptionUserLockedOut": "მომხმარებელი დაიბლოკა",
"NotificationOptionVideoPlayback": "ვიდეოს დაკვრა დაწყებულია",
"PluginInstalledWithName": "{0} დაყენებულია",
"PluginUpdatedWithName": "{0} განახლდა",
"TaskCleanActivityLog": "აქტივობების ჟურნალის გასუფთავება",
"TaskCleanCache": "ეშის საქაღალდის გასუფთავება",
"TaskRefreshChapterImages": "თავის სურათების გაშლა",
"TaskCleanCache": "ეშის საქაღალდის გასუფთავება",
"TaskRefreshChapterImages": "თავის სურათების ამოღება",
"TaskRefreshLibrary": "მედიის ბიბლიოთეკის სკანირება",
"TaskCleanLogs": "ჟურნალის საქაღალდის გასუფთავება",
"TaskCleanTranscode": "ტრანსკოდირების საქაღალდის გასუფთავება",
"TaskDownloadMissingSubtitles": "მიუწვდომელი სუბტიტრების გადმოწერა",
"UserDownloadingItemWithValues": "{0} -ი {1}-ს იწერს",
"TaskDownloadMissingSubtitles": "ნაკლული სუბტიტრების გადმოწერა",
"UserDownloadingItemWithValues": "{0} იწერს {1}-ს",
"FailedLoginAttemptWithUserName": "შესვლის წარუმატებელი მცდელობა {0}-დან",
"UserCreatedWithName": "მომხმარებელი {0} შეიქმნა",
"UserDeletedWithName": "მომხმარებელი {0} წაშლილია",
"UserOnlineFromDevice": "{0}-ი დაკავშირდა {1}-დან",
"UserOfflineFromDevice": "{0}-ი {1}-დან გაეთიშა",
"UserDeletedWithName": "მომხმარებელი {0} წაიშალა",
"UserOnlineFromDevice": "{0} ხაზზეა {1}-დან",
"UserOfflineFromDevice": "{0} გაითიშა {1}-დან",
"UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია",
"UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე",
"UserStartedPlayingItemWithValues": "{0} უკრავს {1}-ს {2}-ზე",
"UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა",
"UserStoppedPlayingItemWithValues": "{0}-მა დაასრულა {1}-ის ყურება {2}-ზე",
"TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.",
@@ -96,16 +96,16 @@
"TaskDownloadMissingSubtitlesDescription": "ეძებს ბიბლიოთეკაში მიუწვდომელ სუბტიტრებს ინტერნეტში მეტამონაცემებზე დაყრდნობით.",
"TaskOptimizeDatabaseDescription": "კუმშავს მონაცემთა ბაზას ადგილის გათავისუფლებლად. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.",
"TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის დაშვებულ ბიბლიოთეკებში.",
"TaskRefreshTrickplayImages": "Trickplay სურათების გენერირება",
"TaskAudioNormalization": "აუდიოს ნორმალიზება",
"TaskRefreshTrickplayImages": "Trickplay სურათების გენერაცია",
"TaskAudioNormalization": "აუდიოს ნორმალიზაცია",
"TaskAudioNormalizationDescription": "აანალიზებს ფაილებს აუდიოს ნორმალიზაციისთვის.",
"TaskDownloadMissingLyrics": "მიუწვდომელი ლირიკების ჩამოტვირთვა",
"TaskDownloadMissingLyricsDescription": "ჩამოტვირთავს ამჟამად ბიბლიოთეკაში არარსებულ ლირიკებს სიმღერებისთვის",
"TaskExtractMediaSegments": "მედი სეგმენტების სკანირება",
"TaskDownloadMissingLyricsDescription": "გადმოწერს ლირიკს სიმღერებისთვის",
"TaskExtractMediaSegments": "მედიის სეგმენტების სკანირება",
"TaskExtractMediaSegmentsDescription": "მედია სეგმენტების სკანირება მხარდაჭერილი მოდულებისთვის.",
"TaskMoveTrickplayImages": "Trickplay სურათების მიგრაცია",
"TaskMoveTrickplayImages": "Trickplay-ის გამოსახულებების მდებარეობის მიგრაცია",
"TaskMoveTrickplayImagesDescription": "გადააქვს trickplay ფაილები ბიბლიოთეკის პარამეტრებზე დაყრდნობით.",
"CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავება",
"CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავების ამოცანა",
"CleanupUserDataTaskDescription": "ასუფთავებს მომხმარებლების მონაცემებს (ყურების სტატუსი, ფავორიტები ანდ ა.შ) მედია ელემენტებისთვის რომლების 90 დღეზე მეტია აღარ არსებობენ.",
"LyricDownloadFailureFromForItem": "{1}-ისთვის {0}-დან ლირიკის გადმოწერა ჩავარდა",
"Original": "ორიგინალი"

View File

@@ -8,7 +8,7 @@
"FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
"Favorites": "Favorieten",
"Folders": "Mappen",
"HeaderContinueWatching": "Verder kijken",
"HeaderContinueWatching": "Verderkijken",
"HeaderFavoriteEpisodes": "Favoriete afleveringen",
"HeaderFavoriteShows": "Favoriete series",
"HeaderLiveTV": "Live-tv",

View File

@@ -1 +1,3 @@
{}
{
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
}

View File

@@ -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.");
}
// Version folder as they cannot be overwritten in Windows.
targetDir += "_" + package.Version;
if (Directory.Exists(targetDir))
{
try
var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
{
Directory.Delete(targetDir, true);
_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;
if (Directory.Exists(targetDir))
{
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);

View File

@@ -88,7 +88,7 @@ public class CollectionController : BaseJellyfinApiController
[FromRoute, Required] Guid collectionId,
[FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
{
await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(false);
return NoContent();
}

View File

@@ -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;
@@ -704,6 +709,72 @@ public class LibraryController : BaseJellyfinApiController
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
}
/// <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>

View File

@@ -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)
return Ok(recommendations.Select(r => new RecommendationDto
{
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)
{
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)
}));
}
}

View File

@@ -145,13 +145,15 @@ public class StartupController : BaseJellyfinApiController
return BadRequest("Password must not be empty");
}
if (startupUserDto.Name is not null)
{
user.Username = startupUserDto.Name;
}
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
#pragma warning disable CA1309 // Use ordinal string comparison
if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase))
{
await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false);
}
#pragma warning restore CA1309 // Use ordinal string comparison
if (!string.IsNullOrEmpty(startupUserDto.Password))
{
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);

View File

@@ -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/>

View File

@@ -166,13 +166,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItem(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
{
if (itemIds.Count == 0)
{
return new Dictionary<Guid, IReadOnlyList<string>>();
}
using var context = _dbProvider.CreateDbContext();
var query = context.PeopleBaseItemMap
.AsNoTracking()
@@ -183,44 +178,16 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
query = query.Where(m => personTypes.Contains(m.People.PersonType));
}
// One round-trip: pull (ItemId, ListOrder, Name) sorted by ItemId+ListOrder, group in memory.
var rows = query
.OrderBy(m => m.ItemId)
.ThenBy(m => m.ListOrder)
.Select(m => new { m.ItemId, m.People.Name })
.ToArray();
var names = query
.Select(m => m.People.Name)
.Distinct();
var result = new Dictionary<Guid, IReadOnlyList<string>>();
List<string>? current = null;
var currentId = Guid.Empty;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
if (limit > 0)
{
if (row.ItemId != currentId)
{
if (current is { Count: > 0 })
{
result[currentId] = current;
}
currentId = row.ItemId;
current = new List<string>();
seen.Clear();
}
if (!string.IsNullOrWhiteSpace(row.Name) && seen.Add(row.Name))
{
current!.Add(row.Name);
}
names = names.Take(limit);
}
if (current is { Count: > 0 })
{
result[currentId] = current;
}
return result;
return names.ToArray();
}
private PersonInfo Map(People people)
@@ -297,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))

View File

@@ -1,4 +1,3 @@
#pragma warning disable CA1307
#pragma warning disable RS0030 // Do not use banned APIs
using System;
@@ -161,12 +160,8 @@ namespace Jellyfin.Server.Implementations.Users
using var dbContext = _dbProvider.CreateDbContext();
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
return UserQuery(dbContext)
.FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper());
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
.FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant());
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
}
@@ -187,10 +182,8 @@ namespace Jellyfin.Server.Implementations.Users
await using (dbContext.ConfigureAwait(false))
{
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId)
.AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId)
.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
@@ -198,8 +191,6 @@ namespace Jellyfin.Server.Implementations.Users
"A user with the name '{0}' already exists.",
newName));
}
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
user = await UserQuery(dbContext)
@@ -208,6 +199,7 @@ namespace Jellyfin.Server.Implementations.Users
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(userId));
user.Username = newName;
user.NormalizedUsername = newName.ToUpperInvariant();
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
}
@@ -257,10 +249,8 @@ namespace Jellyfin.Server.Implementations.Users
await using (dbContext.ConfigureAwait(false))
{
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == name.ToUpper())
.AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant())
.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
@@ -268,8 +258,6 @@ namespace Jellyfin.Server.Implementations.Users
"A user with the name '{0}' already exists.",
name));
}
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);

View File

@@ -193,84 +193,89 @@ internal class JellyfinMigrationService
{
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
var pendingCodeMigrations = migrationStage
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
.Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
.ToArray();
(string Key, IInternalMigration Migration)[] migrations = [];
(string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
{
pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
.Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
.ToArray();
}
do
{ // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply.
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
var pendingCodeMigrations = migrationStage
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
.Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
.ToArray();
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
foreach (var item in migrations)
{
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
(string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
{
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
.Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
.ToArray();
}
catch (Exception ex)
{
migrationLogger.LogCritical("Error: {Error}", ex.Message);
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
foreach (var item in migrations)
{
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
{
if (_backupKey.LibraryDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback librarydb.");
try
{
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
}
if (_backupKey.JellyfinDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
try
{
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
if (_backupKey.FullBackup is not null)
{
migrationLogger.LogInformation("Attempt to rollback from backup.");
try
{
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
}
}
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
}
catch (Exception ex)
{
migrationLogger.LogCritical("Error: {Error}", ex.Message);
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
throw;
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
{
if (_backupKey.LibraryDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback librarydb.");
try
{
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
}
if (_backupKey.JellyfinDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
try
{
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
if (_backupKey.FullBackup is not null)
{
migrationLogger.LogInformation("Attempt to rollback from backup.");
try
{
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
}
}
}
throw;
}
}
}
} while (migrations.Length != 0);
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Part 2 Migration for NormalisedUsername.
/// </summary>
[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
#pragma warning disable SA1649 // File name should match first type name
public class UpdateNormalizedUsername : IAsyncMigrationRoutine
#pragma warning restore SA1649 // File name should match first type name
{
private readonly IDbContextFactory<JellyfinDbContext> _contextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class.
/// </summary>
/// <param name="contextFactory">Db Context factory.</param>
public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var user in users)
{
user.NormalizedUsername = user.Username.ToUpperInvariant();
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -57,6 +57,14 @@ namespace MediaBrowser.Controller.Collections
/// <returns>IEnumerable{BaseItem}.</returns>
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>

View File

@@ -94,6 +94,8 @@ namespace MediaBrowser.Controller.Entities
private string _name;
private string _originalLanguage;
public const char SlugChar = '-';
protected BaseItem()
@@ -217,7 +219,11 @@ namespace MediaBrowser.Controller.Entities
public string OriginalTitle { get; set; }
[JsonIgnore]
public string OriginalLanguage { get; set; }
public string OriginalLanguage
{
get => _originalLanguage;
set => _originalLanguage = LocalizationManager?.FindLanguageInfo(value)?.TwoLetterISOLanguageName ?? value;
}
/// <summary>
/// Gets or sets the id.
@@ -1564,7 +1570,7 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
/// Gets the preferred metadata language.
/// Gets the preferred metadata country code.
/// </summary>
/// <returns>System.String.</returns>
public string GetPreferredMetadataCountryCode()
@@ -1598,6 +1604,15 @@ namespace MediaBrowser.Controller.Entities
return lang;
}
/// <summary>
/// Gets the original language of the item, inheriting from parent items if necessary.
/// </summary>
/// <returns>System.String.</returns>
public virtual string GetInheritedOriginalLanguage()
{
return OriginalLanguage;
}
public virtual bool IsSaveLocalMetadataEnabled()
{
if (SourceType == SourceType.Channel)

View File

@@ -153,6 +153,12 @@ namespace MediaBrowser.Controller.Entities.TV
return 16.0 / 9;
}
/// <inheritdoc />
public override string GetInheritedOriginalLanguage()
{
return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage();
}
public override List<string> GetUserDataKeys()
{
var list = base.GetUserDataKeys();

View File

@@ -128,6 +128,12 @@ namespace MediaBrowser.Controller.Entities.TV
return result;
}
/// <inheritdoc />
public override string GetInheritedOriginalLanguage()
{
return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage();
}
public override string CreatePresentationUniqueKey()
{
if (IndexNumber.HasValue)

View File

@@ -278,6 +278,17 @@ namespace MediaBrowser.Controller.Entities
return linkedVersionCount + localVersionCount + 1;
}
/// <inheritdoc />
public override string GetInheritedOriginalLanguage()
{
if (ExtraType.GetValueOrDefault() == Model.Entities.ExtraType.Trailer)
{
return GetOwner()?.GetInheritedOriginalLanguage();
}
return OriginalLanguage ?? GetOwner()?.GetInheritedOriginalLanguage();
}
public override List<string> GetUserDataKeys()
{
var list = base.GetUserDataKeys();

View File

@@ -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);
}

View File

@@ -598,12 +598,13 @@ namespace MediaBrowser.Controller.Library
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query);
/// <summary>
/// Gets the people names per item for a batch of item IDs in a single DB round-trip.
/// Gets distinct people names for multiple items.
/// </summary>
/// <param name="itemIds">The item IDs to look up.</param>
/// <param name="personTypes">Optional person types to include. Empty for all.</param>
/// <returns>Dictionary keyed by item id; values are the per-item people names. Items with no people are absent.</returns>
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItem(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
/// <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.

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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)
@@ -2014,11 +2056,15 @@ namespace MediaBrowser.Controller.MediaEncoding
args += keyFrameArg + gopArg;
}
// global_header produced by AMD HEVC VA-API encoder causes non-playable fMP4 on iOS
// The in-band Parameter Sets generated by the AMD HEVC VA-API encoder is inconsistent
// with the extradata generated by ffmpeg, causing decoding failures when using hvc1.
if (string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
&& _mediaEncoder.IsVaapiDeviceAmd)
{
args += " -flags:v -global_header";
// Extracting the extradata from the in-band PS to bypass the issue.
// This can be removed once the issue is resolved in libva or Mesa.
// Transcoding is unavoidable here, so using BSF will not conflict with BSF in remuxing.
args += " -flags:v -global_header -bsf:v extract_extradata=remove=0";
}
return args;
@@ -3002,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;

View File

@@ -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.

View File

@@ -34,12 +34,11 @@ public interface IPeopleRepository
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter);
/// <summary>
/// Gets the people names per item for a batch of item IDs, preserving per-item list order.
/// One database round-trip for the whole batch; grouped by item id in memory.
/// Items with no people are omitted from the returned dictionary.
/// 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">Optional person types to include (e.g. "Actor", "Director"). Empty for all.</param>
/// <returns>Dictionary keyed by item id; values are the per-item people names.</returns>
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItem(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
/// <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);
}

View File

@@ -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; }
}

View File

@@ -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.

View File

@@ -413,7 +413,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
}
item.Overview = result.Plot;
item.OriginalLanguage = result.Language;
item.OriginalLanguage = result.Language?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
if (!Plugin.Instance.Configuration.CastAndCrew)
{

View File

@@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
Username = username;
NormalizedUsername = username.ToUpperInvariant();
AuthenticationProviderId = authenticationProviderId;
PasswordResetProviderId = passwordResetProviderId;
@@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities
[StringLength(255)]
public string Username { get; set; }
/// <summary>
/// Gets or sets the user's normalized name.
/// </summary>
/// <remarks>
/// Required, Max length = 255.
/// </remarks>
[MaxLength(255)]
[StringLength(255)]
public string NormalizedUsername { get; set; }
/// <summary>
/// Gets or sets the user's password, or <c>null</c> if none is set.
/// </summary>

View File

@@ -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
}

View File

@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
builder
.HasIndex(entity => entity.Username)
.IsUnique();
builder
.HasIndex(entity => entity.NormalizedUsername)
.IsUnique();
}
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddNormalizedUsername : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NormalizedUsername",
table: "Users",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: string.Empty);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("ALTER TABLE Users DROP COLUMN NormalizedUsername;");
migrationBuilder.Sql(
@"DELETE FROM __EFMigrationsHistory
WHERE MigrationId = '20260522092304_UpdateNormalizedUsername'");
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddUniqueNormalizedUsernameIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Users_NormalizedUsername",
table: "Users",
column: "NormalizedUsername",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_NormalizedUsername",
table: "Users");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -1348,6 +1348,11 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<bool>("MustUpdatePassword")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(65535)
.HasColumnType("TEXT");
@@ -1390,6 +1395,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.HasIndex("Username")
.IsUnique();

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,240 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public sealed class UserManagerNormalizedUsernameTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly UserManager _userManager;
public UserManagerNormalizedUsernameTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
// Create the schema
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateDbContext);
var cryptoProvider = new Mock<ICryptoProvider>();
var configManager = new Mock<IServerConfigurationManager>();
var appPaths = new Mock<IServerApplicationPaths>();
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
var appHost = new Mock<IApplicationHost>();
var defaultAuthProvider = new DefaultAuthenticationProvider(
NullLogger<DefaultAuthenticationProvider>.Instance,
cryptoProvider.Object);
var invalidAuthProvider = new InvalidAuthProvider();
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
configManager.Object,
appHost.Object);
_userManager = new UserManager(
factory.Object,
new NoopEventManager(),
new Mock<INetworkManager>().Object,
appHost.Object,
new Mock<IImageProcessor>().Object,
NullLogger<UserManager>.Instance,
configManager.Object,
new IPasswordResetProvider[] { defaultPasswordResetProvider },
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
}
public void Dispose()
{
_userManager.Dispose();
_connection.Dispose();
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
// ----- GetUserByName tests -----
[Theory]
// German umlauts
[InlineData("münchen", "MÜNCHEN")]
// Spanish tilde-n
[InlineData("Ñoño", "ÑOÑO")]
// ASCII, invariant uppercase lookup
[InlineData("jellyfin", "JELLYFIN")]
// Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
[InlineData("Çelebi", "ÇELEBI")]
public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
string username, string normalizedLookup)
{
await _userManager.CreateUserAsync(username);
var found = _userManager.GetUserByName(normalizedLookup);
Assert.NotNull(found);
Assert.Equal(username, found.Username);
}
[Theory]
// German umlaut, look up by both upper and lower case
[InlineData("münchen")]
// Spanish tilde-n
[InlineData("Ñoño")]
// lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
[InlineData("ali")]
// mixed ASCII + umlaut
[InlineData("testüser")]
public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
{
await _userManager.CreateUserAsync(username);
var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
var exactFound = _userManager.GetUserByName(username);
Assert.NotNull(upperFound);
Assert.NotNull(lowerFound);
Assert.NotNull(exactFound);
}
[Theory]
[InlineData("nonexistent")]
// No user with NormalizedUsername = "MÜNCHEN" has been created
[InlineData("MÜNCHEN")]
public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
{
var result = _userManager.GetUserByName(lookupName);
Assert.Null(result);
}
// ----- CreateUserAsync duplicate detection tests -----
[Theory]
// German umlaut, case-swapped duplicate
[InlineData("münchen", "MÜNCHEN")]
// Spanish tilde-n, lowercase duplicate
[InlineData("Ñoño", "ñoño")]
// ASCII, uppercase duplicate
[InlineData("alice", "ALICE")]
// Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
[InlineData("çelebi", "ÇELEBI")]
public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
string existingUsername, string duplicateUsername)
{
await _userManager.CreateUserAsync(existingUsername);
await Assert.ThrowsAsync<ArgumentException>(
() => _userManager.CreateUserAsync(duplicateUsername));
}
[Theory]
// Different non-ASCII names that do not collide after normalization
[InlineData("münchen", "münchen2")]
[InlineData("ali", "ali2")]
// Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
[InlineData("noño", "nono")]
public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
string firstUsername, string secondUsername)
{
var first = await _userManager.CreateUserAsync(firstUsername);
var second = await _userManager.CreateUserAsync(secondUsername);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.NotEqual(first.Id, second.Id);
}
// ----- RenameUser tests -----
[Theory]
// Rename to non-ASCII name
[InlineData("alice", "münchen")]
// Rename between similar non-ASCII and ASCII
[InlineData("müller", "mueller")]
// Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
[InlineData("ali", "ALI2")]
// Rename to Spanish tilde-n name
[InlineData("testuser", "Ñoño")]
public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
string originalName, string newName)
{
var user = await _userManager.CreateUserAsync(originalName);
await _userManager.RenameUser(user.Id, originalName, newName);
var renamed = _userManager.GetUserById(user.Id);
Assert.NotNull(renamed);
Assert.Equal(newName, renamed.Username);
Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
}
[Theory]
// Same name different case: NormalizedUsername already taken
[InlineData("münchen", "MÜNCHEN")]
// Spanish, lowercase conflicts with existing uppercase-normalised entry
[InlineData("Ñoño", "ñoño")]
// ASCII, capitalised conflict
[InlineData("alice", "Alice")]
// Mixed ASCII + umlaut
[InlineData("testüser", "TESTÜSER")]
public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
string existingUsername, string conflictingNewName)
{
var targetUser = await _userManager.CreateUserAsync("renametarget");
await _userManager.CreateUserAsync(existingUsername);
await Assert.ThrowsAsync<ArgumentException>(
() => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
}
private sealed class NoopEventManager : IEventManager
{
public void Publish<T>(T eventArgs)
where T : EventArgs
{
}
public Task PublishAsync<T>(T eventArgs)
where T : EventArgs
=> Task.CompletedTask;
}
}
}

View File

@@ -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")]