mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-08 00:39:25 +01:00
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:
1
.github/ISSUE_TEMPLATE/issue report.yml
vendored
1
.github/ISSUE_TEMPLATE/issue report.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ci-codeql-analysis.yml
vendored
2
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
4
.github/workflows/ci-compat.yml
vendored
4
.github/workflows/ci-compat.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
2
.github/workflows/ci-format.yml
vendored
2
.github/workflows/ci-format.yml
vendored
@@ -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 }}
|
||||
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -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 }}
|
||||
|
||||
|
||||
2
.github/workflows/openapi-generate.yml
vendored
2
.github/workflows/openapi-generate.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
4
.github/workflows/pull-request-conflict.yml
vendored
4
.github/workflows/pull-request-conflict.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -278,3 +278,7 @@ apiclient/generated
|
||||
|
||||
# Omnisharp crash logs
|
||||
mono_crash.*.json
|
||||
|
||||
# Devcontainer temp files
|
||||
.devcontainer/devcontainer-lock.json
|
||||
dotnet/
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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 יום לפחות."
|
||||
}
|
||||
|
||||
@@ -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": "ორიგინალი"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{}
|
||||
{
|
||||
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
|
||||
builder
|
||||
.HasIndex(entity => entity.Username)
|
||||
.IsUnique();
|
||||
|
||||
builder
|
||||
.HasIndex(entity => entity.NormalizedUsername)
|
||||
.IsUnique();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user