diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml
index a7e644a55f..0689db7a87 100644
--- a/.github/ISSUE_TEMPLATE/issue report.yml
+++ b/.github/ISSUE_TEMPLATE/issue report.yml
@@ -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
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index cf4cc1c7f1..8d3e8fe6d5 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -27,7 +27,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '10.0.x'
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index dd48209a1f..99aa56f54f 100644
--- a/.github/workflows/ci-compat.yml
+++ b/.github/workflows/ci-compat.yml
@@ -17,7 +17,7 @@ jobs:
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '10.0.x'
@@ -47,7 +47,7 @@ jobs:
fetch-depth: 0
- name: Setup .NET
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '10.0.x'
diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml
index c2cca262bf..d6ba603fbb 100644
--- a/.github/workflows/ci-format.yml
+++ b/.github/workflows/ci-format.yml
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 3c7ba54acf..928249d93e 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -22,7 +22,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml
index dbfaf9d30b..c2a5199c54 100644
--- a/.github/workflows/openapi-generate.yml
+++ b/.github/workflows/openapi-generate.yml
@@ -28,7 +28,7 @@ jobs:
repository: ${{ inputs.repository }}
- name: Configure .NET
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '10.0.x'
diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml
index 32628ac912..3f6af02a48 100644
--- a/.github/workflows/pull-request-conflict.yml
+++ b/.github/workflows/pull-request-conflict.yml
@@ -15,8 +15,8 @@ jobs:
if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }}
steps:
- name: Apply label
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
- if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
+ uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
+ if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
diff --git a/.gitignore b/.gitignore
index e399f1fc47..381c15909d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -278,3 +278,7 @@ apiclient/generated
# Omnisharp crash logs
mono_crash.*.json
+
+# Devcontainer temp files
+.devcontainer/devcontainer-lock.json
+dotnet/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f568f7e781..d0df007071 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,7 +18,7 @@
-
+
@@ -47,7 +47,7 @@
-
+
@@ -68,10 +68,9 @@
-
-
-
-
+
+
+
@@ -79,7 +78,7 @@
-
+
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index 0ede5665f9..295efd456c 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -4,12 +4,15 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections
private readonly ILibraryMonitor _iLibraryMonitor;
private readonly ILogger _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
/// The library monitor.
/// The logger factory.
/// The provider manager.
+ /// The linked children service.
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();
_providerManager = providerManager;
+ _linkedChildrenService = linkedChildrenService;
_localizationManager = localizationManager;
_appPaths = appPaths;
}
@@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
}
+ ///
+ public IEnumerable GetCollectionsContainingItem(User user, Guid itemId)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (itemId.IsEmpty())
+ {
+ return Enumerable.Empty();
+ }
+
+ return _linkedChildrenService
+ .GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet)
+ .Select(parentId => _libraryManager.GetItemById(parentId, user))
+ .OfType();
+ }
+
private IEnumerable GetCollections(User user)
{
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 662e28ec1d..cc85f09d23 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -3395,9 +3395,9 @@ namespace Emby.Server.Implementations.Library
}
///
- public IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes)
+ public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit)
{
- return _peopleRepository.GetPeopleNamesByItem(itemIds, personTypes);
+ return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit);
}
public void UpdatePeople(BaseItem item, List people)
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index fdb4c7328b..66614c6725 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -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);
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
index 93aa0574c0..b4ed12a20c 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -1,36 +1,72 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
-using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
namespace Emby.Server.Implementations.Library.SimilarItems;
///
-/// Provides similar items for movies and trailers.
+/// Provides similar items for movies and trailers using weighted scoring.
///
-public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider
+public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider, 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 _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 _dbProvider;
+ private readonly IItemQueryHelpers _queryHelpers;
private readonly IServerConfigurationManager _serverConfigurationManager;
///
/// Initializes a new instance of the class.
///
- /// The library manager.
+ /// The database context factory.
+ /// The shared query helpers.
/// The server configuration manager.
public MovieSimilarItemsProvider(
- ILibraryManager libraryManager,
+ IDbContextFactory dbProvider,
+ IItemQueryHelpers queryHelpers,
IServerConfigurationManager serverConfigurationManager)
{
- _libraryManager = libraryManager;
+ _dbProvider = dbProvider;
+ _queryHelpers = queryHelpers;
_serverConfigurationManager = serverConfigurationManager;
}
@@ -41,15 +77,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider MetadataPluginType.LocalSimilarityProvider;
///
- public Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
+ public async Task> 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 : [];
}
///
- public Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
+ public async Task> 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 throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
};
- private IReadOnlyList GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query)
+ ///
+ public async Task>> GetBatchSimilarItemsAsync(
+ IReadOnlyList sourceItems,
+ SimilarItemsQuery query,
+ CancellationToken cancellationToken)
{
var includeItemTypes = new List { 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();
+ foreach (var (_, scores) in perSourceScores)
+ {
+ allCandidateIds.UnionWith(
+ scores.OrderByDescending(kvp => kvp.Value)
+ .Take(limit * 3)
+ .Select(kvp => kvp.Key));
+ }
+
+ var result = new Dictionary>();
+ 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();
+ var perSourceOrderedIds = new Dictionary>();
+
+ 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>> ComputeBatchScoresAsync(List sourceIds, JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ var result = new Dictionary>();
+ 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(
+ List sourceIds,
+ Dictionary> sourceMap,
+ Dictionary> keyToCandidates,
+ int weight,
+ Dictionary> result)
+ where TKey : notnull
+ {
+ foreach (var sourceId in sourceIds)
+ {
+ if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
+ {
+ continue;
+ }
+
+ var scoreMap = result[sourceId];
+ foreach (var key in sourceKeys)
+ {
+ if (!keyToCandidates.TryGetValue(key, out var candidates))
+ {
+ continue;
+ }
+
+ foreach (var candidateId in candidates)
+ {
+ scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
+ }
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
index b56779cf3f..358c170db2 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
@@ -8,12 +8,16 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions.Json;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
@@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly IFileSystem _fileSystem;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
private ISimilarItemsProvider[] _similarItemsProviders = [];
///
@@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager
/// The server application paths.
/// The library manager.
/// The file system.
+ /// The server configuration manager.
public SimilarItemsManager(
ILogger logger,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
- IFileSystem fileSystem)
+ IFileSystem fileSystem,
+ IServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_appPaths = appPaths;
_libraryManager = libraryManager;
_fileSystem = fileSystem;
+ _serverConfigurationManager = serverConfigurationManager;
}
///
@@ -225,6 +233,211 @@ public class SimilarItemsManager : ISimilarItemsManager
.ToList();
}
+ ///
+ public async Task> 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.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 to box the List.Enumerator struct once;
+ // using var would box separately per list insertion, creating independent copies.
+ IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
+ IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator();
+
+ var categoryTypes = new List>
+ {
+ similarToRecentlyPlayedEnum,
+ similarToRecentlyPlayedEnum,
+ similarToLikedEnum,
+ similarToLikedEnum,
+ hasDirectorFromRecentlyPlayed.GetEnumerator(),
+ hasActorFromRecentlyPlayed.GetEnumerator()
+ };
+
+ var categories = new List();
+ 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> GetSimilarItemsRecommendationsAsync(
+ IReadOnlyList baselineItems,
+ RecommendationType recommendationType,
+ SimilarItemsQuery query,
+ CancellationToken cancellationToken)
+ {
+ var batchProvider = _similarItemsProviders
+ .OfType()
+ .FirstOrDefault();
+
+ if (batchProvider is null || baselineItems.Count == 0)
+ {
+ return [];
+ }
+
+ var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
+
+ var recommendations = new List(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 GetPersonRecommendations(
+ User? user,
+ IReadOnlyList names,
+ int itemLimit,
+ DtoOptions dtoOptions,
+ RecommendationType type,
+ IReadOnlyList itemTypes)
+ {
+ var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
+ ? [PersonType.Director]
+ : Array.Empty();
+
+ 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 GetPeopleNames(IReadOnlyList items, IReadOnlyList personTypes)
+ {
+ var itemIds = items.Select(i => i.Id).ToArray();
+ return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0);
+ }
+
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
IReadOnlyList references,
int providerOrder,
diff --git a/Emby.Server.Implementations/Localization/Core/enm.json b/Emby.Server.Implementations/Localization/Core/enm.json
deleted file mode 100644
index 0967ef424b..0000000000
--- a/Emby.Server.Implementations/Localization/Core/enm.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json
index 35efcf74d3..563dce8fe6 100644
--- a/Emby.Server.Implementations/Localization/Core/es.json
+++ b/Emby.Server.Implementations/Localization/Core/es.json
@@ -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}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json
index dedbc56a74..b551608fd0 100644
--- a/Emby.Server.Implementations/Localization/Core/he_IL.json
+++ b/Emby.Server.Implementations/Localization/Core/he_IL.json
@@ -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 יום לפחות."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json
index 5245d89948..f7ca19d7f0 100644
--- a/Emby.Server.Implementations/Localization/Core/ka.json
+++ b/Emby.Server.Implementations/Localization/Core/ka.json
@@ -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": "ორიგინალი"
diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json
index 898f5892c9..9aea3adc22 100644
--- a/Emby.Server.Implementations/Localization/Core/nl.json
+++ b/Emby.Server.Implementations/Localization/Core/nl.json
@@ -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",
diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json
index 0967ef424b..cad5640763 100644
--- a/Emby.Server.Implementations/Localization/Core/oc.json
+++ b/Emby.Server.Implementations/Localization/Core/oc.json
@@ -1 +1,3 @@
-{}
+{
+ "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
+}
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 67b77a112d..ef53e3b326 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -527,42 +527,44 @@ namespace Emby.Server.Implementations.Updates
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
- await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-
- // CA5351: Do Not Use Broken Cryptographic Algorithms
+ Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ // CA5351: Do Not Use Broken Cryptographic Algorithms
#pragma warning disable CA5351
- cancellationToken.ThrowIfCancellationRequested();
+ cancellationToken.ThrowIfCancellationRequested();
- var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
- if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogError(
- "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
- package.Name,
- package.Checksum,
- hash);
- throw new InvalidDataException("The checksum of the received data doesn't match.");
- }
-
- // 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);
diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs
index 227487b390..aa2b24c1e7 100644
--- a/Jellyfin.Api/Controllers/CollectionController.cs
+++ b/Jellyfin.Api/Controllers/CollectionController.cs
@@ -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();
}
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index abf27b7702..6a30a80f1d 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -17,6 +17,7 @@ using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -50,6 +51,7 @@ public class LibraryController : BaseJellyfinApiController
private readonly ISimilarItemsManager _similarItemsManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
+ private readonly ICollectionManager _collectionManager;
private readonly IDtoService _dtoService;
private readonly IActivityManager _activityManager;
private readonly ILocalizationManager _localization;
@@ -64,6 +66,7 @@ public class LibraryController : BaseJellyfinApiController
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
+ /// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
@@ -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);
}
+ ///
+ /// Gets the collections that include the specified item.
+ ///
+ /// The item id.
+ /// Optional. Filter by user id, and attach user data.
+ /// Optional. The index of the first record in the output.
+ /// Optional. The maximum number of records to return.
+ /// Optional. Specify additional fields of information to return in the output.
+ /// Collections returned.
+ /// User context missing.
+ /// Item not found.
+ /// The collections that contain the requested item.
+ [HttpGet("Items/{itemId}/Collections")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult> 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(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 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(
+ startIndex,
+ visibleCollections.Count,
+ dtos);
+ }
+
///
/// Gets similar items.
///
diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs
index 50d34d0656..a1f2fe7ce7 100644
--- a/Jellyfin.Api/Controllers/MoviesController.cs
+++ b/Jellyfin.Api/Controllers/MoviesController.cs
@@ -1,17 +1,13 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
-using Jellyfin.Data.Enums;
-using Jellyfin.Database.Implementations.Entities;
-using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@@ -30,27 +26,23 @@ namespace Jellyfin.Api.Controllers;
public class MoviesController : BaseJellyfinApiController
{
private readonly IUserManager _userManager;
- private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
- private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly ISimilarItemsManager _similarItemsManager;
///
/// Initializes a new instance of the class.
///
/// Instance of the interface.
- /// Instance of the interface.
/// Instance of the interface.
- /// Instance of the interface.
+ /// Instance of the interface.
public MoviesController(
IUserManager userManager,
- ILibraryManager libraryManager,
IDtoService dtoService,
- IServerConfigurationManager serverConfigurationManager)
+ ISimilarItemsManager similarItemsManager)
{
_userManager = userManager;
- _libraryManager = libraryManager;
_dtoService = dtoService;
- _serverConfigurationManager = serverConfigurationManager;
+ _similarItemsManager = similarItemsManager;
}
///
@@ -61,15 +53,17 @@ public class MoviesController : BaseJellyfinApiController
/// Optional. The fields to return.
/// The max number of categories to return.
/// The max number of items to return per category.
+ /// The cancellation token.
/// Movie recommendations returned.
/// The list of movie recommendations.
[HttpGet("Recommendations")]
- public ActionResult> GetMovieRecommendations(
+ public async Task>> 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();
+ 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.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>
- {
- // 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 GetWithDirector(
- User? user,
- IEnumerable names,
- int itemLimit,
- DtoOptions dtoOptions,
- RecommendationType type)
- {
- var itemTypes = new List { 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 GetWithActor(User? user, IEnumerable names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
- {
- var itemTypes = new List { 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 GetSimilarTo(User? user, IEnumerable baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
- {
- var itemTypes = new List { 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 GetActors(IEnumerable items)
- {
- var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty(), 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 GetDirectors(IEnumerable items)
- {
- var people = _libraryManager.GetPeople(new InternalPeopleQuery(
- new[] { PersonType.Director },
- Array.Empty()));
-
- var itemIds = items.Select(i => i.Id).ToList();
-
- return people
- .Where(i => itemIds.Contains(i.ItemId))
- .Select(i => i.Name)
- .DistinctNames();
+ BaselineItemName = r.BaselineItemName,
+ CategoryId = r.CategoryId,
+ RecommendationType = r.RecommendationType,
+ Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user)
+ }));
}
}
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index 4373a46adc..fa6d9efe36 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -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);
diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
index 9e11b6be62..5e5ce320a5 100644
--- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
+++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
@@ -91,14 +91,25 @@ public class LinkedChildrenService : ILinkedChildrenService
}
///
- public IReadOnlyList GetManualLinkedParentIds(Guid childId)
+ public IReadOnlyList 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();
}
///
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index d84a59850d..6062aaca2f 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -166,13 +166,8 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
}
///
- public IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes)
+ public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit)
{
- if (itemIds.Count == 0)
- {
- return new Dictionary>();
- }
-
using var context = _dbProvider.CreateDbContext();
var query = context.PeopleBaseItemMap
.AsNoTracking()
@@ -183,44 +178,16 @@ public class PeopleRepository(IDbContextFactory 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>();
- List? current = null;
- var currentId = Guid.Empty;
- var seen = new HashSet(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();
- 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 dbProvider, I
if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value);
+ query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value));
}
if (!string.IsNullOrWhiteSpace(filter.NameContains))
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 8c0cbbd448..37c4106496 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -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);
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index d664b718bc..9bf927bb95 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -193,84 +193,89 @@ internal class JellyfinMigrationService
{
var historyRepository = dbContext.GetService();
var migrationsAssembly = dbContext.GetService();
- 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);
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs
new file mode 100644
index 0000000000..8100d4759e
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs
@@ -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;
+
+///
+/// Part 2 Migration for NormalisedUsername.
+///
+[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 _contextFactory;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Db Context factory.
+ public UpdateNormalizedUsername(IDbContextFactory contextFactory)
+ {
+ _contextFactory = contextFactory;
+ }
+
+ ///
+ 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);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs
index 206b5ac426..8d5d54ffd9 100644
--- a/MediaBrowser.Controller/Collections/ICollectionManager.cs
+++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs
@@ -57,6 +57,14 @@ namespace MediaBrowser.Controller.Collections
/// IEnumerable{BaseItem}.
IEnumerable CollapseItemsWithinBoxSets(IEnumerable items, User user);
+ ///
+ /// Gets the collections accessible to the supplied user that contain the provided item.
+ ///
+ /// The user.
+ /// The item identifier.
+ /// The collections containing the item.
+ IEnumerable GetCollectionsContainingItem(User user, Guid itemId);
+
///
/// Gets the folder where collections are stored.
///
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 4cdcaabbb1..e24b60f69f 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -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;
+ }
///
/// Gets or sets the id.
@@ -1564,7 +1570,7 @@ namespace MediaBrowser.Controller.Entities
}
///
- /// Gets the preferred metadata language.
+ /// Gets the preferred metadata country code.
///
/// System.String.
public string GetPreferredMetadataCountryCode()
@@ -1598,6 +1604,15 @@ namespace MediaBrowser.Controller.Entities
return lang;
}
+ ///
+ /// Gets the original language of the item, inheriting from parent items if necessary.
+ ///
+ /// System.String.
+ public virtual string GetInheritedOriginalLanguage()
+ {
+ return OriginalLanguage;
+ }
+
public virtual bool IsSaveLocalMetadataEnabled()
{
if (SourceType == SourceType.Channel)
diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs
index dbe6f94dfd..42e4f79942 100644
--- a/MediaBrowser.Controller/Entities/TV/Episode.cs
+++ b/MediaBrowser.Controller/Entities/TV/Episode.cs
@@ -153,6 +153,12 @@ namespace MediaBrowser.Controller.Entities.TV
return 16.0 / 9;
}
+ ///
+ public override string GetInheritedOriginalLanguage()
+ {
+ return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage();
+ }
+
public override List GetUserDataKeys()
{
var list = base.GetUserDataKeys();
diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs
index f70f7dfb4c..e96ed05a5e 100644
--- a/MediaBrowser.Controller/Entities/TV/Season.cs
+++ b/MediaBrowser.Controller/Entities/TV/Season.cs
@@ -128,6 +128,12 @@ namespace MediaBrowser.Controller.Entities.TV
return result;
}
+ ///
+ public override string GetInheritedOriginalLanguage()
+ {
+ return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage();
+ }
+
public override string CreatePresentationUniqueKey()
{
if (IndexNumber.HasValue)
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 80bcd62dcd..44cae5197a 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -278,6 +278,17 @@ namespace MediaBrowser.Controller.Entities
return linkedVersionCount + localVersionCount + 1;
}
+ ///
+ public override string GetInheritedOriginalLanguage()
+ {
+ if (ExtraType.GetValueOrDefault() == Model.Entities.ExtraType.Trailer)
+ {
+ return GetOwner()?.GetInheritedOriginalLanguage();
+ }
+
+ return OriginalLanguage ?? GetOwner()?.GetInheritedOriginalLanguage();
+ }
+
public override List GetUserDataKeys()
{
var list = base.GetUserDataKeys();
diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs
new file mode 100644
index 0000000000..af49711606
--- /dev/null
+++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Library;
+
+///
+/// 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.
+///
+public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider
+{
+ ///
+ /// Gets similar items for multiple source items in a single batch.
+ ///
+ /// The source items to find similar items for.
+ /// The query options.
+ /// The cancellation token.
+ /// Per-source-item results keyed by source item ID.
+ Task>> GetBatchSimilarItemsAsync(
+ IReadOnlyList sourceItems,
+ SimilarItemsQuery query,
+ CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index d794205f00..c23eba75ef 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -598,12 +598,13 @@ namespace MediaBrowser.Controller.Library
IReadOnlyList GetPeopleNames(InternalPeopleQuery query);
///
- /// 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.
///
- /// The item IDs to look up.
- /// Optional person types to include. Empty for all.
- /// Dictionary keyed by item id; values are the per-item people names. Items with no people are absent.
- IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes);
+ /// The item IDs.
+ /// The person types to include.
+ /// Maximum number of names.
+ /// The distinct people names.
+ IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit);
///
/// Queries the items.
diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
index 0ced6f71ee..36fa547eeb 100644
--- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
+++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
@@ -6,6 +6,7 @@ using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Library;
@@ -47,4 +48,23 @@ public interface ISimilarItemsManager
int? limit,
LibraryOptions? libraryOptions,
CancellationToken cancellationToken);
+
+ ///
+ /// Builds movie recommendations for a user: a mix of similar-items and person-based categories,
+ /// scheduled round-robin and capped to .
+ ///
+ /// The user the recommendations are for. May be for anonymous access.
+ /// The library/folder to localize the search to. Pass to use the root.
+ /// Maximum number of recommendation categories to return.
+ /// Maximum number of items per category.
+ /// DTO options used when querying the library.
+ /// The cancellation token.
+ /// The list of recommendation categories, ordered by .
+ Task> GetMovieRecommendationsAsync(
+ User? user,
+ Guid parentId,
+ int categoryLimit,
+ int itemLimit,
+ DtoOptions dtoOptions,
+ CancellationToken cancellationToken);
}
diff --git a/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs
new file mode 100644
index 0000000000..71346fcadf
--- /dev/null
+++ b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Dto;
+
+namespace MediaBrowser.Controller.Library;
+
+///
+/// A recommendation category derived from a baseline item, holding similar items prior to DTO conversion.
+///
+public sealed class SimilarItemsRecommendation
+{
+ ///
+ /// Gets the display name of the baseline item the recommendation is based on.
+ ///
+ public required string BaselineItemName { get; init; }
+
+ ///
+ /// Gets an identifier for the recommendation category.
+ ///
+ public required Guid CategoryId { get; init; }
+
+ ///
+ /// Gets the recommendation type.
+ ///
+ public required RecommendationType RecommendationType { get; init; }
+
+ ///
+ /// Gets the similar items for the baseline, ordered by relevance.
+ ///
+ public required IReadOnlyList Items { get; init; }
+}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 8f6e36bce4..ff8d84d45e 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -86,6 +86,7 @@ namespace MediaBrowser.Controller.MediaEncoding
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0);
+ private readonly Version _minFFmpegNoiseBsfDrop = new Version(5, 0);
private static readonly string[] _videoProfilesH264 =
[
@@ -1547,20 +1548,61 @@ namespace MediaBrowser.Controller.MediaEncoding
public string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer)
{
- var bitStreamArgs = string.Empty;
+ var filters = new List();
+
+ 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;
diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
index d0cddf54a6..a4614fc125 100644
--- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
+++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.Audio;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
@@ -29,8 +30,9 @@ public interface ILinkedChildrenService
/// Gets parent IDs that reference the specified child with LinkedChildType.Manual.
///
/// The child item ID.
+ /// Optional parent item type filter.
/// List of parent IDs that reference the child.
- IReadOnlyList GetManualLinkedParentIds(Guid childId);
+ IReadOnlyList GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null);
///
/// Updates LinkedChildren references from one child to another.
diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
index 3a3b2bfb1f..7474130ec4 100644
--- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
@@ -34,12 +34,11 @@ public interface IPeopleRepository
IReadOnlyList GetPeopleNames(InternalPeopleQuery filter);
///
- /// 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.
///
/// The item IDs to get people for.
- /// Optional person types to include (e.g. "Actor", "Director"). Empty for all.
- /// Dictionary keyed by item id; values are the per-item people names.
- IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes);
+ /// The person types to include (e.g. "Actor", "Director").
+ /// Maximum number of names to return.
+ /// The distinct people names.
+ IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit);
}
diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs
index 98fc2e632f..f5bb5330ed 100644
--- a/MediaBrowser.Model/Configuration/EncodingOptions.cs
+++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs
@@ -61,7 +61,7 @@ public class EncodingOptions
SubtitleExtractionTimeoutMinutes = 30;
AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = ["mkv"];
HardwareDecodingCodecs = ["h264", "vc1"];
- HlsAudioSeekStrategy = HlsAudioSeekStrategy.DisableAccurateSeek;
+ HlsAudioSeekStrategy = HlsAudioSeekStrategy.TrimCopiedAudio;
}
///
@@ -307,6 +307,6 @@ public class EncodingOptions
///
/// Gets or sets the method used for audio seeking in HLS.
///
- [DefaultValue(HlsAudioSeekStrategy.DisableAccurateSeek)]
+ [DefaultValue(HlsAudioSeekStrategy.TrimCopiedAudio)]
public HlsAudioSeekStrategy HlsAudioSeekStrategy { get; set; }
}
diff --git a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs
index 49feeb435f..c9155faeb1 100644
--- a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs
+++ b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs
@@ -7,11 +7,12 @@ namespace MediaBrowser.Model.Configuration
public enum HlsAudioSeekStrategy
{
///
- /// 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).
///
- DisableAccurateSeek = 0,
+ TrimCopiedAudio = 0,
///
/// Prevent audio streams from being copied if the video stream is transcoded.
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index 4882822766..f562d64ddd 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -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)
{
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
index 6c81fa729c..b10e210e5d 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
@@ -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; }
+ ///
+ /// Gets or sets the user's normalized name.
+ ///
+ ///
+ /// Required, Max length = 255.
+ ///
+ [MaxLength(255)]
+ [StringLength(255)]
+ public string NormalizedUsername { get; set; }
+
///
/// Gets or sets the user's password, or null if none is set.
///
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs
index b2bcbf2bb6..34810b9199 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs
@@ -108,5 +108,50 @@ public enum ViewType
///
/// Shows upcoming.
///
- Upcoming = 20
+ Upcoming = 20,
+
+ ///
+ /// Shows authors.
+ ///
+ Authors = 21,
+
+ ///
+ /// Shows books.
+ ///
+ Books = 22,
+
+ ///
+ /// Shows folders.
+ ///
+ Folders = 23,
+
+ ///
+ /// Shows mixed media.
+ ///
+ Mixed = 24,
+
+ ///
+ /// Shows photos.
+ ///
+ Photos = 25,
+
+ ///
+ /// Shows photo albums.
+ ///
+ PhotoAlbums = 26,
+
+ ///
+ /// Shows series timers.
+ ///
+ SeriesTimers = 27,
+
+ ///
+ /// Shows studios.
+ ///
+ Studios = 28,
+
+ ///
+ /// Shows videos.
+ ///
+ Videos = 29
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs
index 61b5e06e8a..ed4138680d 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs
@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
builder
.HasIndex(entity => entity.Username)
.IsUnique();
+
+ builder
+ .HasIndex(entity => entity.NormalizedUsername)
+ .IsUnique();
}
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs
new file mode 100644
index 0000000000..63f858bc98
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs
@@ -0,0 +1,1804 @@
+//
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20260522092303_AddNormalizedUsername")]
+ partial class AddNormalizedUsername
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Album")
+ .HasColumnType("TEXT");
+
+ b.Property("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property("Data")
+ .HasColumnType("TEXT");
+
+ b.Property("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("Path")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("SeasonId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("SeriesName");
+
+ b.HasIndex("ExtraType", "OwnerId");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "CleanName");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem")
+ .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "SortName");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detached from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId", "ImageType");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ItemId", "ProviderValue");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property