mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-08 00:39:25 +01:00
Merge remote-tracking branch 'upstream/master' into search-rebased
# Conflicts: # Emby.Server.Implementations/Library/LibraryManager.cs # Jellyfin.Server.Implementations/Item/PeopleRepository.cs # MediaBrowser.Controller/Library/ILibraryManager.cs # MediaBrowser.Controller/Persistence/IPeopleRepository.cs
This commit is contained in:
@@ -4,12 +4,15 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Collections;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections
|
||||
private readonly ILibraryMonitor _iLibraryMonitor;
|
||||
private readonly ILogger<CollectionManager> _logger;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly ILinkedChildrenService _linkedChildrenService;
|
||||
private readonly ILocalizationManager _localizationManager;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
||||
@@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections
|
||||
/// <param name="iLibraryMonitor">The library monitor.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="providerManager">The provider manager.</param>
|
||||
/// <param name="linkedChildrenService">The linked children service.</param>
|
||||
public CollectionManager(
|
||||
ILibraryManager libraryManager,
|
||||
IApplicationPaths appPaths,
|
||||
@@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections
|
||||
IFileSystem fileSystem,
|
||||
ILibraryMonitor iLibraryMonitor,
|
||||
ILoggerFactory loggerFactory,
|
||||
IProviderManager providerManager)
|
||||
IProviderManager providerManager,
|
||||
ILinkedChildrenService linkedChildrenService)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_fileSystem = fileSystem;
|
||||
_iLibraryMonitor = iLibraryMonitor;
|
||||
_logger = loggerFactory.CreateLogger<CollectionManager>();
|
||||
_providerManager = providerManager;
|
||||
_linkedChildrenService = linkedChildrenService;
|
||||
_localizationManager = localizationManager;
|
||||
_appPaths = appPaths;
|
||||
}
|
||||
@@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections
|
||||
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
if (itemId.IsEmpty())
|
||||
{
|
||||
return Enumerable.Empty<BoxSet>();
|
||||
}
|
||||
|
||||
return _linkedChildrenService
|
||||
.GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet)
|
||||
.Select(parentId => _libraryManager.GetItemById<BoxSet>(parentId, user))
|
||||
.OfType<BoxSet>();
|
||||
}
|
||||
|
||||
private IEnumerable<BoxSet> GetCollections(User user)
|
||||
{
|
||||
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
|
||||
|
||||
@@ -3395,9 +3395,9 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItem(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
|
||||
public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
|
||||
{
|
||||
return _peopleRepository.GetPeopleNamesByItem(itemIds, personTypes);
|
||||
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit);
|
||||
}
|
||||
|
||||
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
|
||||
|
||||
@@ -440,10 +440,6 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage)
|
||||
? originalLanguage.Split(',').FirstOrDefault()
|
||||
: null;
|
||||
|
||||
if (user.PlayDefaultAudioTrack)
|
||||
{
|
||||
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(
|
||||
@@ -498,17 +494,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
|
||||
|
||||
var originalLanguage = item?.OriginalLanguage ?? item switch
|
||||
{
|
||||
Episode episode => episode.Series.OriginalLanguage,
|
||||
Video video => video.GetOwner() switch
|
||||
{
|
||||
Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage,
|
||||
BaseItem owner => owner.OriginalLanguage,
|
||||
null => null
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
var originalLanguage = item?.GetInheritedOriginalLanguage();
|
||||
|
||||
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage);
|
||||
SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
|
||||
|
||||
@@ -1,36 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Provides similar items for movies and trailers.
|
||||
/// Provides similar items for movies and trailers using weighted scoring.
|
||||
/// </summary>
|
||||
public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>
|
||||
public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, IBatchLocalSimilarItemsProvider
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private const int GenreWeight = 10;
|
||||
private const int TagWeight = 5;
|
||||
private const int StudioWeight = 5;
|
||||
private const int DirectorWeight = 50;
|
||||
private const int ActorWeight = 15;
|
||||
|
||||
// Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id
|
||||
// load, navigation includes) stay bounded regardless of caller input.
|
||||
private const int MaxBatchSourceItems = 64;
|
||||
|
||||
private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions =
|
||||
[
|
||||
(ItemValueType.Genre, GenreWeight),
|
||||
(ItemValueType.Tags, TagWeight),
|
||||
(ItemValueType.Studios, StudioWeight)
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal)
|
||||
{
|
||||
[nameof(PersonKind.Director)] = DirectorWeight,
|
||||
[nameof(PersonKind.Actor)] = ActorWeight,
|
||||
[nameof(PersonKind.GuestStar)] = ActorWeight,
|
||||
};
|
||||
|
||||
private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys];
|
||||
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared query helpers.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
public MovieSimilarItemsProvider(
|
||||
ILibraryManager libraryManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
@@ -41,15 +77,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(GetSimilarMovieItems(item, query));
|
||||
var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
|
||||
return results.TryGetValue(item.Id, out var items) ? items : [];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(GetSimilarMovieItems(item, query));
|
||||
var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
|
||||
return results.TryGetValue(item.Id, out var items) ? items : [];
|
||||
}
|
||||
|
||||
bool ILocalSimilarItemsProvider.Supports(Type itemType)
|
||||
@@ -63,29 +101,233 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
_ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
|
||||
};
|
||||
|
||||
private IReadOnlyList<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query)
|
||||
/// <inheritdoc/>
|
||||
public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
|
||||
IReadOnlyList<BaseItemDto> sourceItems,
|
||||
SimilarItemsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
includeItemTypes.Add(BaseItemKind.Trailer);
|
||||
includeItemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
var internalQuery = new InternalItemsQuery(query.User)
|
||||
{
|
||||
Genres = item.Genres,
|
||||
Tags = item.Tags,
|
||||
Limit = query.Limit,
|
||||
DtoOptions = query.DtoOptions ?? new DtoOptions(),
|
||||
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||
IncludeItemTypes = [.. includeItemTypes],
|
||||
EnableGroupByMetadataKey = true,
|
||||
EnableTotalRecordCount = false,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
|
||||
};
|
||||
var limit = query.Limit ?? 50;
|
||||
var dtoOptions = query.DtoOptions ?? new DtoOptions();
|
||||
|
||||
return _libraryManager.GetItemList(internalQuery);
|
||||
if (sourceItems.Count > MaxBatchSourceItems)
|
||||
{
|
||||
sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
|
||||
}
|
||||
|
||||
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
// Phase 1: Score all candidates per source item
|
||||
var sourceIds = sourceItems.Select(i => i.Id).ToList();
|
||||
var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var allCandidateIds = new HashSet<Guid>();
|
||||
foreach (var (_, scores) in perSourceScores)
|
||||
{
|
||||
allCandidateIds.UnionWith(
|
||||
scores.OrderByDescending(kvp => kvp.Value)
|
||||
.Take(limit * 3)
|
||||
.Select(kvp => kvp.Key));
|
||||
}
|
||||
|
||||
var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
|
||||
if (allCandidateIds.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Phase 2: One access filter for all candidates
|
||||
var filter = new InternalItemsQuery(query.User)
|
||||
{
|
||||
IncludeItemTypes = [.. includeItemTypes],
|
||||
ExcludeItemIds = [.. query.ExcludeItemIds],
|
||||
DtoOptions = dtoOptions,
|
||||
EnableGroupByMetadataKey = true,
|
||||
EnableTotalRecordCount = false,
|
||||
IsMovie = true,
|
||||
IsPlayed = false
|
||||
};
|
||||
|
||||
_queryHelpers.PrepareFilterQuery(filter);
|
||||
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
|
||||
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
|
||||
|
||||
var allCandidateIdsList = allCandidateIds.ToList();
|
||||
var accessibleItems = await baseQuery
|
||||
.WhereOneOrMany(allCandidateIdsList, e => e.Id)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
|
||||
var allOrderedIds = new HashSet<Guid>();
|
||||
var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
|
||||
|
||||
foreach (var item in sourceItems)
|
||||
{
|
||||
if (!perSourceScores.TryGetValue(item.Id, out var scores))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var orderedIds = accessibleItems
|
||||
.Where(x => scores.ContainsKey(x.Id))
|
||||
.OrderByDescending(x => scores.GetValueOrDefault(x.Id))
|
||||
.DistinctBy(x => x.PresentationUniqueKey)
|
||||
.Take(limit)
|
||||
.Select(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
if (orderedIds.Count > 0)
|
||||
{
|
||||
perSourceOrderedIds[item.Id] = orderedIds;
|
||||
allOrderedIds.UnionWith(orderedIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (allOrderedIds.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Phase 4: One entity load for all results
|
||||
var allOrderedIdsList = allOrderedIds.ToList();
|
||||
var entities = await _queryHelpers.ApplyNavigations(
|
||||
context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id),
|
||||
filter)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entitiesById = entities
|
||||
.Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization))
|
||||
.Where(dto => dto is not null)
|
||||
.ToDictionary(i => i!.Id);
|
||||
|
||||
// Phase 5: Split by source, preserving score order
|
||||
foreach (var (sourceId, orderedIds) in perSourceOrderedIds)
|
||||
{
|
||||
var items = orderedIds
|
||||
.Where(entitiesById.ContainsKey)
|
||||
.Select(id => entitiesById[id]!)
|
||||
.ToList();
|
||||
|
||||
if (items.Count > 0)
|
||||
{
|
||||
result[sourceId] = items;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, JellyfinDbContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new Dictionary<Guid, Dictionary<Guid, int>>();
|
||||
foreach (var id in sourceIds)
|
||||
{
|
||||
result[id] = [];
|
||||
}
|
||||
|
||||
foreach (var (valueType, weight) in _itemValueDimensions)
|
||||
{
|
||||
var sourceRows = await context.ItemValuesMap.AsNoTracking()
|
||||
.Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType)
|
||||
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet());
|
||||
var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList();
|
||||
if (allKeys.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateRows = await context.ItemValuesMap.AsNoTracking()
|
||||
.Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
|
||||
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
|
||||
ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result);
|
||||
}
|
||||
|
||||
var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking()
|
||||
.Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType))
|
||||
.Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (personSourceRows.Count > 0)
|
||||
{
|
||||
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
|
||||
.Where(m => context.PeopleBaseItemMap
|
||||
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
|
||||
.Select(s => s.PeopleId)
|
||||
.Contains(m.PeopleId))
|
||||
.Select(m => new { m.ItemId, m.PeopleId })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var personToCandidates = personCandidateRows
|
||||
.GroupBy(r => r.PeopleId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
|
||||
|
||||
foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!]))
|
||||
{
|
||||
var sourceMap = weightGroup
|
||||
.GroupBy(r => r.ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet());
|
||||
ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var sourceId in sourceIds)
|
||||
{
|
||||
var scoreMap = result[sourceId];
|
||||
scoreMap.Remove(sourceId);
|
||||
if (scoreMap.Count == 0)
|
||||
{
|
||||
result.Remove(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyDimensionScores<TKey>(
|
||||
List<Guid> sourceIds,
|
||||
Dictionary<Guid, HashSet<TKey>> sourceMap,
|
||||
Dictionary<TKey, List<Guid>> keyToCandidates,
|
||||
int weight,
|
||||
Dictionary<Guid, Dictionary<Guid, int>> result)
|
||||
where TKey : notnull
|
||||
{
|
||||
foreach (var sourceId in sourceIds)
|
||||
{
|
||||
if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scoreMap = result[sourceId];
|
||||
foreach (var key in sourceKeys)
|
||||
{
|
||||
if (!keyToCandidates.TryGetValue(key, out var candidates))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidateId in candidates)
|
||||
{
|
||||
scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,16 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
@@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
private readonly IServerApplicationPaths _appPaths;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private ISimilarItemsProvider[] _similarItemsProviders = [];
|
||||
|
||||
/// <summary>
|
||||
@@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
/// <param name="appPaths">The server application paths.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
public SimilarItemsManager(
|
||||
ILogger<SimilarItemsManager> logger,
|
||||
IServerApplicationPaths appPaths,
|
||||
ILibraryManager libraryManager,
|
||||
IFileSystem fileSystem)
|
||||
IFileSystem fileSystem,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_libraryManager = libraryManager;
|
||||
_fileSystem = fileSystem;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -225,6 +233,211 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
|
||||
User? user,
|
||||
Guid parentId,
|
||||
int categoryLimit,
|
||||
int itemLimit,
|
||||
DtoOptions dtoOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dtoOptions);
|
||||
|
||||
var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Movie],
|
||||
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)],
|
||||
Limit = 7,
|
||||
ParentId = parentId,
|
||||
Recursive = true,
|
||||
IsPlayed = true,
|
||||
EnableGroupByMetadataKey = true,
|
||||
DtoOptions = dtoOptions
|
||||
});
|
||||
|
||||
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
itemTypes.Add(BaseItemKind.Trailer);
|
||||
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = itemTypes.ToArray(),
|
||||
IsMovie = true,
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Descending)],
|
||||
Limit = 10,
|
||||
IsFavoriteOrLiked = true,
|
||||
ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
|
||||
EnableGroupByMetadataKey = true,
|
||||
ParentId = parentId,
|
||||
Recursive = true,
|
||||
DtoOptions = dtoOptions
|
||||
});
|
||||
|
||||
var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
|
||||
var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]);
|
||||
var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]);
|
||||
|
||||
// Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
|
||||
var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
|
||||
? recentlyPlayedMovies.Take(categoryLimit).ToList()
|
||||
: recentlyPlayedMovies;
|
||||
var likedBaseline = likedMovies.Count > categoryLimit
|
||||
? likedMovies.Take(categoryLimit).ToList()
|
||||
: likedMovies;
|
||||
|
||||
var batchQuery = new SimilarItemsQuery
|
||||
{
|
||||
User = user,
|
||||
Limit = itemLimit,
|
||||
DtoOptions = dtoOptions
|
||||
};
|
||||
|
||||
var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync(
|
||||
recentlyPlayedBaseline,
|
||||
RecommendationType.SimilarToRecentlyPlayed,
|
||||
batchQuery,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var similarToLiked = await GetSimilarItemsRecommendationsAsync(
|
||||
likedBaseline,
|
||||
RecommendationType.SimilarToLikedItem,
|
||||
batchQuery,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes);
|
||||
var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes);
|
||||
|
||||
// Use a single enumerator per list, listed twice so MoveNext advances it
|
||||
// twice per round-robin pass (giving these categories double weight).
|
||||
// IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
|
||||
// using var would box separately per list insertion, creating independent copies.
|
||||
IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
|
||||
IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
|
||||
|
||||
var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
|
||||
{
|
||||
similarToRecentlyPlayedEnum,
|
||||
similarToRecentlyPlayedEnum,
|
||||
similarToLikedEnum,
|
||||
similarToLikedEnum,
|
||||
hasDirectorFromRecentlyPlayed.GetEnumerator(),
|
||||
hasActorFromRecentlyPlayed.GetEnumerator()
|
||||
};
|
||||
|
||||
var categories = new List<SimilarItemsRecommendation>();
|
||||
while (categories.Count < categoryLimit)
|
||||
{
|
||||
var allEmpty = true;
|
||||
foreach (var category in categoryTypes)
|
||||
{
|
||||
if (category.MoveNext())
|
||||
{
|
||||
categories.Add(category.Current);
|
||||
allEmpty = false;
|
||||
|
||||
if (categories.Count >= categoryLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allEmpty)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [.. categories.OrderBy(i => i.RecommendationType)];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
|
||||
IReadOnlyList<BaseItem> baselineItems,
|
||||
RecommendationType recommendationType,
|
||||
SimilarItemsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var batchProvider = _similarItemsProviders
|
||||
.OfType<IBatchLocalSimilarItemsProvider>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (batchProvider is null || baselineItems.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
|
||||
foreach (var baseline in baselineItems)
|
||||
{
|
||||
if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
|
||||
{
|
||||
recommendations.Add(new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = baseline.Name,
|
||||
CategoryId = baseline.Id,
|
||||
RecommendationType = recommendationType,
|
||||
Items = similar
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations(
|
||||
User? user,
|
||||
IReadOnlyList<string> names,
|
||||
int itemLimit,
|
||||
DtoOptions dtoOptions,
|
||||
RecommendationType type,
|
||||
IReadOnlyList<BaseItemKind> itemTypes)
|
||||
{
|
||||
var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
|
||||
? [PersonType.Director]
|
||||
: Array.Empty<string>();
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
Person = name,
|
||||
Limit = itemLimit + 2,
|
||||
PersonTypes = personTypes,
|
||||
IncludeItemTypes = itemTypes.ToArray(),
|
||||
IsMovie = true,
|
||||
IsPlayed = false,
|
||||
EnableGroupByMetadataKey = true,
|
||||
DtoOptions = dtoOptions
|
||||
})
|
||||
.DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
|
||||
.Take(itemLimit)
|
||||
.ToList();
|
||||
|
||||
if (items.Count > 0)
|
||||
{
|
||||
yield return new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = name,
|
||||
CategoryId = name.GetMD5(),
|
||||
RecommendationType = type,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
|
||||
{
|
||||
var itemIds = items.Select(i => i.Id).ToArray();
|
||||
return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0);
|
||||
}
|
||||
|
||||
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
|
||||
IReadOnlyList<SimilarItemReference> references,
|
||||
int providerOrder,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -108,5 +108,5 @@
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}."
|
||||
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}"
|
||||
}
|
||||
|
||||
@@ -16,5 +16,97 @@
|
||||
"HeaderLiveTV": "טלוויזיה בשידור חי",
|
||||
"HeaderNextUp": "הבא",
|
||||
"HearingImpaired": "ללקויי שמיעה",
|
||||
"HomeVideos": "סרטונים ביתיים"
|
||||
"HomeVideos": "סרטונים ביתיים",
|
||||
"AppDeviceValues": "אפליקציה: {0}, מכשיר: {1}",
|
||||
"AuthenticationSucceededWithUserName": "{0} אומת בהצלחה",
|
||||
"Default": "בררת מחדל",
|
||||
"FailedLoginAttemptWithUserName": "התחברות נכשלה מ {0}",
|
||||
"Forced": "בכוח",
|
||||
"Inherit": "ירש",
|
||||
"LabelIpAddressValue": "כתובת IP: {0}",
|
||||
"LabelRunningTimeValue": "זמן ריצה: {0}",
|
||||
"Latest": "הכי חדש",
|
||||
"LyricDownloadFailureFromForItem": "מילות שיר נכשלו לרדת מ{0} בשביל {1}",
|
||||
"MixedContent": "תוכן מעורב",
|
||||
"MusicVideos": "סרטוני מוזיקה",
|
||||
"NameInstallFailed": "{0} התכנות כושלות",
|
||||
"NameSeasonUnknown": "עונה לא ידוע",
|
||||
"NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "גרסת אפליקציה חדשה זמינה להורדה",
|
||||
"NotificationOptionApplicationUpdateInstalled": "עדכון אפליקציה הותקן",
|
||||
"NotificationOptionAudioPlayback": "החלה השמעת אודיו",
|
||||
"NotificationOptionAudioPlaybackStopped": "ניגון השמע הופסק",
|
||||
"NotificationOptionCameraImageUploaded": "תמונת מצלמה עודכן",
|
||||
"NotificationOptionInstallationFailed": "התקנה נכשלה",
|
||||
"NotificationOptionNewLibraryContent": "תוכן חדש נוסף",
|
||||
"NotificationOptionPluginError": "תוסף נכשל",
|
||||
"NotificationOptionPluginInstalled": "תוסף הותקן",
|
||||
"NotificationOptionPluginUninstalled": "תוסף נמחק",
|
||||
"NotificationOptionPluginUpdateInstalled": "עידכון לתוסף הותקן",
|
||||
"NotificationOptionServerRestartRequired": "נדרש התחול מחדש לשרת",
|
||||
"NotificationOptionTaskFailed": "כשל במשימה מתוכננת",
|
||||
"NotificationOptionUserLockedOut": "המשתמש ננעל",
|
||||
"NotificationOptionVideoPlayback": "החלה הפעלת וידאו",
|
||||
"NotificationOptionVideoPlaybackStopped": "הפעלת הסרטון הופסקה",
|
||||
"Original": "מקורי",
|
||||
"Photos": "תמונות",
|
||||
"PluginInstalledWithName": "{0} הותקן",
|
||||
"PluginUninstalledWithName": "{0} נמחק",
|
||||
"PluginUpdatedWithName": "{0} עודכן",
|
||||
"ScheduledTaskFailedWithName": "{0} נכשל",
|
||||
"Shows": "סדרות",
|
||||
"StartupEmbyServerIsLoading": "שרת Jellyfin נטען. אנא נסה שוב בקרוב.",
|
||||
"SubtitleDownloadFailureFromForItem": "הורדת הכתוביות מ-{0} עבור {1} נכשלה",
|
||||
"TvShows": "תוכניות טלויזיה",
|
||||
"Undefined": "לא מוגדר",
|
||||
"UserCreatedWithName": "המשתמש {0} נוצר",
|
||||
"UserDeletedWithName": "המשתמש {0} נמחק",
|
||||
"UserDownloadingItemWithValues": "{0} מוריד את {1}",
|
||||
"UserLockedOutWithName": "המשתמש {0} ננעל בחוץ",
|
||||
"UserOfflineFromDevice": "{0} התנתק מ-{1}",
|
||||
"UserOnlineFromDevice": "{0} מחובר מ-{1}",
|
||||
"UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} מנגן ב-{1} ב-{2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} ב-{2}",
|
||||
"VersionNumber": "גרסה {0}",
|
||||
"TasksMaintenanceCategory": "תחזוקה",
|
||||
"TasksLibraryCategory": "ספריה",
|
||||
"TasksApplicationCategory": "אפליקציה",
|
||||
"TasksChannelsCategory": "ערוצי אינטרנט",
|
||||
"TaskCleanActivityLog": "נקה יומן פעילות",
|
||||
"TaskCleanActivityLogDescription": "מוחק רשומות יומן פעילות ישנות יותר מהגיל שהוגדר.",
|
||||
"TaskCleanCache": "נקה ספריית מטמון",
|
||||
"TaskCleanCacheDescription": "מוחק קבצי מטמון שאינם נחוצים עוד על ידי המערכת.",
|
||||
"TaskRefreshChapterImages": "חלץ תמונות פרק",
|
||||
"TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות עבור סרטונים שיש להם פרקים.",
|
||||
"TaskAudioNormalization": "נורמליזציה של שמע",
|
||||
"TaskAudioNormalizationDescription": "סורק קבצים לאיתור נתוני נרמול שמע.",
|
||||
"TaskRefreshLibrary": "סרוק ספריית מדיה",
|
||||
"TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך לאיתור קבצים חדשים ומרענן מטא-דאטה.",
|
||||
"TaskCleanLogs": "נקה ספריית יומן",
|
||||
"TaskCleanLogsDescription": "מוחק קבצי יומן שגילם עולה על {0} ימים.",
|
||||
"TaskRefreshPeople": "רענן אנשים",
|
||||
"TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.",
|
||||
"TaskRefreshTrickplayImages": "צור תמונות Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "יוצר תצוגות מקדימות של trickplay עבור סרטונים בספריות מופעלות.",
|
||||
"TaskUpdatePlugins": "עדכן פלאגינים",
|
||||
"TaskUpdatePluginsDescription": "מוריד ומתקין עדכונים עבור תוספים שתצורתם נקבעה לעדכון אוטומטי.",
|
||||
"TaskCleanTranscode": "נקה ספריית קידוד",
|
||||
"TaskCleanTranscodeDescription": "תמחוק את קבצי הקידוד בני יותר מיום.",
|
||||
"TaskRefreshChannels": "רענן ערוצים",
|
||||
"TaskRefreshChannelsDescription": "מרענן את פרטי ערוץ האינטרנט.",
|
||||
"TaskDownloadMissingLyrics": "הורד מילות שיר חסרות",
|
||||
"TaskDownloadMissingLyricsDescription": "הורדות מילים לשירים",
|
||||
"TaskDownloadMissingSubtitles": "הורד כתוביות חסרות",
|
||||
"TaskDownloadMissingSubtitlesDescription": "מחפש באינטרנט אחר כתוביות חסרות בהתבסס על תצורת מטא-דאטה.",
|
||||
"TaskOptimizeDatabase": "בצע אופטימיזציה של מסד הנתונים",
|
||||
"TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים וקיצוץ שטח פנוי. הפעלת משימה זו לאחר סריקת הספרייה או ביצוע שינויים אחרים שמשמעותם שינויים בבסיס הנתונים עשויה לשפר את הביצועים.",
|
||||
"TaskKeyframeExtractor": "מחלץ פריים מרכזי",
|
||||
"TaskKeyframeExtractorDescription": "מחלץ פריימים מרכזיים מקבצי וידאו כדי ליצור רשימות השמעה HLS מדויקות יותר. משימה זו עשויה להימשך זמן רב.",
|
||||
"TaskExtractMediaSegments": "סריקת מקטעי מדיה",
|
||||
"TaskExtractMediaSegmentsDescription": "מחלץ או משיג קטעי מדיה מתוספים התומכים ב-MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "העברת מיקום תמונת Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "מעביר קבצי trickplay קיימים בהתאם להגדרות הספרייה.",
|
||||
"CleanupUserDataTask": "משימת ניקוי נתוני משתמש",
|
||||
"CleanupUserDataTaskDescription": "מנקה את כל נתוני המשתמש (מצב צפייה, סטטוס מועדף וכו') ממדיה שכבר לא הייתה קיימת במשך 90 יום לפחות."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"External": "გარე",
|
||||
"HeaderFavoriteEpisodes": "რჩეული ეპიზოდები",
|
||||
"HearingImpaired": "სმენადაქვეითებული",
|
||||
"LabelRunningTimeValue": "ხანგრძლივობა: {0}",
|
||||
"LabelRunningTimeValue": "გაშვების დრო: {0}",
|
||||
"MixedContent": "შერეული შემცველობა",
|
||||
"MusicVideos": "მუსიკის ვიდეოები",
|
||||
"NotificationOptionInstallationFailed": "დაყენების შეცდომა",
|
||||
@@ -31,7 +31,7 @@
|
||||
"PluginUninstalledWithName": "{0} წაიშალა",
|
||||
"VersionNumber": "ვერსია {0}",
|
||||
"TasksChannelsCategory": "ინტერნეტ-არხები",
|
||||
"TaskRefreshChannelsDescription": "ინტერნეტ-არხის ინფორმაციის განახლება.",
|
||||
"TaskRefreshChannelsDescription": "განაახლებს ინტერნეტ-არხის ინფორმაციას.",
|
||||
"Collections": "კოლექციები",
|
||||
"Default": "ნაგულისხმევი",
|
||||
"Favorites": "რჩეულები",
|
||||
@@ -53,32 +53,32 @@
|
||||
"TaskOptimizeDatabase": "მონაცემთა ბაზის ოპტიმიზაცია",
|
||||
"TaskKeyframeExtractor": "საკვანძო კადრის გამომღები",
|
||||
"LabelIpAddressValue": "IP მისამართი: {0}",
|
||||
"NameInstallFailed": "{0}-ის დაყენების შეცდომა",
|
||||
"NameInstallFailed": "{0}-ის დაყენების ჩავარდა",
|
||||
"NotificationOptionApplicationUpdateAvailable": "ხელმისაწვდომია აპლიკაციის განახლება",
|
||||
"NotificationOptionAudioPlaybackStopped": "აუდიოს დაკვრა გაჩერებულია",
|
||||
"NotificationOptionNewLibraryContent": "ახალი შემცველობა დამატებულია",
|
||||
"NotificationOptionPluginUpdateInstalled": "მოდულიs განახლება დაყენებულია",
|
||||
"NotificationOptionPluginUpdateInstalled": "დამატების განახლება დაყენებულია",
|
||||
"NotificationOptionServerRestartRequired": "საჭიროა სერვერის გადატვირთვა",
|
||||
"NotificationOptionTaskFailed": "გეგმიური დავალების შეცდომა",
|
||||
"NotificationOptionTaskFailed": "დაგეგმილი ამოცანა ჩავარდა",
|
||||
"NotificationOptionUserLockedOut": "მომხმარებელი დაიბლოკა",
|
||||
"NotificationOptionVideoPlayback": "ვიდეოს დაკვრა დაწყებულია",
|
||||
"PluginInstalledWithName": "{0} დაყენებულია",
|
||||
"PluginUpdatedWithName": "{0} განახლდა",
|
||||
"TaskCleanActivityLog": "აქტივობების ჟურნალის გასუფთავება",
|
||||
"TaskCleanCache": "ქეშის საქაღალდის გასუფთავება",
|
||||
"TaskRefreshChapterImages": "თავის სურათების გაშლა",
|
||||
"TaskCleanCache": "კეშის საქაღალდის გასუფთავება",
|
||||
"TaskRefreshChapterImages": "თავის სურათების ამოღება",
|
||||
"TaskRefreshLibrary": "მედიის ბიბლიოთეკის სკანირება",
|
||||
"TaskCleanLogs": "ჟურნალის საქაღალდის გასუფთავება",
|
||||
"TaskCleanTranscode": "ტრანსკოდირების საქაღალდის გასუფთავება",
|
||||
"TaskDownloadMissingSubtitles": "მიუწვდომელი სუბტიტრების გადმოწერა",
|
||||
"UserDownloadingItemWithValues": "{0} -ი {1}-ს იწერს",
|
||||
"TaskDownloadMissingSubtitles": "ნაკლული სუბტიტრების გადმოწერა",
|
||||
"UserDownloadingItemWithValues": "{0} იწერს {1}-ს",
|
||||
"FailedLoginAttemptWithUserName": "შესვლის წარუმატებელი მცდელობა {0}-დან",
|
||||
"UserCreatedWithName": "მომხმარებელი {0} შეიქმნა",
|
||||
"UserDeletedWithName": "მომხმარებელი {0} წაშლილია",
|
||||
"UserOnlineFromDevice": "{0}-ი დაკავშირდა {1}-დან",
|
||||
"UserOfflineFromDevice": "{0}-ი {1}-დან გაეთიშა",
|
||||
"UserDeletedWithName": "მომხმარებელი {0} წაიშალა",
|
||||
"UserOnlineFromDevice": "{0} ხაზზეა {1}-დან",
|
||||
"UserOfflineFromDevice": "{0} გაითიშა {1}-დან",
|
||||
"UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია",
|
||||
"UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე",
|
||||
"UserStartedPlayingItemWithValues": "{0} უკრავს {1}-ს {2}-ზე",
|
||||
"UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა",
|
||||
"UserStoppedPlayingItemWithValues": "{0}-მა დაასრულა {1}-ის ყურება {2}-ზე",
|
||||
"TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.",
|
||||
@@ -96,16 +96,16 @@
|
||||
"TaskDownloadMissingSubtitlesDescription": "ეძებს ბიბლიოთეკაში მიუწვდომელ სუბტიტრებს ინტერნეტში მეტამონაცემებზე დაყრდნობით.",
|
||||
"TaskOptimizeDatabaseDescription": "კუმშავს მონაცემთა ბაზას ადგილის გათავისუფლებლად. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.",
|
||||
"TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის დაშვებულ ბიბლიოთეკებში.",
|
||||
"TaskRefreshTrickplayImages": "Trickplay სურათების გენერირება",
|
||||
"TaskAudioNormalization": "აუდიოს ნორმალიზება",
|
||||
"TaskRefreshTrickplayImages": "Trickplay სურათების გენერაცია",
|
||||
"TaskAudioNormalization": "აუდიოს ნორმალიზაცია",
|
||||
"TaskAudioNormalizationDescription": "აანალიზებს ფაილებს აუდიოს ნორმალიზაციისთვის.",
|
||||
"TaskDownloadMissingLyrics": "მიუწვდომელი ლირიკების ჩამოტვირთვა",
|
||||
"TaskDownloadMissingLyricsDescription": "ჩამოტვირთავს ამჟამად ბიბლიოთეკაში არარსებულ ლირიკებს სიმღერებისთვის",
|
||||
"TaskExtractMediaSegments": "მედია სეგმენტების სკანირება",
|
||||
"TaskDownloadMissingLyricsDescription": "გადმოწერს ლირიკას სიმღერებისთვის",
|
||||
"TaskExtractMediaSegments": "მედიის სეგმენტების სკანირება",
|
||||
"TaskExtractMediaSegmentsDescription": "მედია სეგმენტების სკანირება მხარდაჭერილი მოდულებისთვის.",
|
||||
"TaskMoveTrickplayImages": "Trickplay სურათების მიგრაცია",
|
||||
"TaskMoveTrickplayImages": "Trickplay-ის გამოსახულებების მდებარეობის მიგრაცია",
|
||||
"TaskMoveTrickplayImagesDescription": "გადააქვს trickplay ფაილები ბიბლიოთეკის პარამეტრებზე დაყრდნობით.",
|
||||
"CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავება",
|
||||
"CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავების ამოცანა",
|
||||
"CleanupUserDataTaskDescription": "ასუფთავებს მომხმარებლების მონაცემებს (ყურების სტატუსი, ფავორიტები ანდ ა.შ) მედია ელემენტებისთვის რომლების 90 დღეზე მეტია აღარ არსებობენ.",
|
||||
"LyricDownloadFailureFromForItem": "{1}-ისთვის {0}-დან ლირიკის გადმოწერა ჩავარდა",
|
||||
"Original": "ორიგინალი"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
|
||||
"Favorites": "Favorieten",
|
||||
"Folders": "Mappen",
|
||||
"HeaderContinueWatching": "Verder kijken",
|
||||
"HeaderContinueWatching": "Verderkijken",
|
||||
"HeaderFavoriteEpisodes": "Favoriete afleveringen",
|
||||
"HeaderFavoriteShows": "Favoriete series",
|
||||
"HeaderLiveTV": "Live-tv",
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{}
|
||||
{
|
||||
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
|
||||
}
|
||||
|
||||
@@ -527,42 +527,44 @@ namespace Emby.Server.Implementations.Updates
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// CA5351: Do Not Use Broken Cryptographic Algorithms
|
||||
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
// CA5351: Do Not Use Broken Cryptographic Algorithms
|
||||
#pragma warning disable CA5351
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
|
||||
if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
|
||||
package.Name,
|
||||
package.Checksum,
|
||||
hash);
|
||||
throw new InvalidDataException("The checksum of the received data doesn't match.");
|
||||
}
|
||||
|
||||
// Version folder as they cannot be overwritten in Windows.
|
||||
targetDir += "_" + package.Version;
|
||||
|
||||
if (Directory.Exists(targetDir))
|
||||
{
|
||||
try
|
||||
var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
|
||||
if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Directory.Delete(targetDir, true);
|
||||
_logger.LogError(
|
||||
"The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
|
||||
package.Name,
|
||||
package.Checksum,
|
||||
hash);
|
||||
throw new InvalidDataException("The checksum of the received data doesn't match.");
|
||||
}
|
||||
|
||||
// Version folder as they cannot be overwritten in Windows.
|
||||
targetDir += "_" + package.Version;
|
||||
|
||||
if (Directory.Exists(targetDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(targetDir, true);
|
||||
}
|
||||
#pragma warning disable CA1031 // Do not catch general exception types
|
||||
catch
|
||||
catch
|
||||
#pragma warning restore CA1031 // Do not catch general exception types
|
||||
{
|
||||
// Ignore any exceptions.
|
||||
{
|
||||
// Ignore any exceptions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken);
|
||||
stream.Position = 0;
|
||||
await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Ensure we create one or populate existing ones with missing data.
|
||||
await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user