aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs17
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs25
-rw-r--r--Emby.Server.Implementations/Devices/DeviceId.cs13
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs67
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs6
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs2
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs42
-rw-r--r--Emby.Server.Implementations/Library/ExternalDataManager.cs40
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs38
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs147
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs6
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs8
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs12
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs8
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchManager.cs458
-rw-r--r--Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs230
-rw-r--r--Emby.Server.Implementations/Library/SearchEngine.cs200
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs303
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs256
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs137
-rw-r--r--Emby.Server.Implementations/Localization/Core/az.json19
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/enm.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-AR.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_419.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json37
-rw-r--r--Emby.Server.Implementations/Localization/Core/gl.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/he_IL.json94
-rw-r--r--Emby.Server.Implementations/Localization/Core/id.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/is.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ka.json38
-rw-r--r--Emby.Server.Implementations/Localization/Core/kn.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/oc.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/si.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sq.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sr.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sw.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json4
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs52
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/gr.json27
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/se.json13
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs3
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs3
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs1
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs16
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs142
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs73
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs15
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs3
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs92
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs97
62 files changed, 2387 insertions, 471 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index c81829688f..69e23bcb63 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -26,6 +26,7 @@ using Emby.Server.Implementations.Dto;
using Emby.Server.Implementations.HttpServer.Security;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
+using Emby.Server.Implementations.Library.Search;
using Emby.Server.Implementations.Library.SimilarItems;
using Emby.Server.Implementations.Localization;
using Emby.Server.Implementations.Playlists;
@@ -92,6 +93,9 @@ using MediaBrowser.Model.Net;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.System;
using MediaBrowser.Model.Tasks;
+using MediaBrowser.Providers.Books;
+using MediaBrowser.Providers.Books.ComicBookInfo;
+using MediaBrowser.Providers.Books.ComicInfo;
using MediaBrowser.Providers.Lyric;
using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Plugins.ListenBrainz;
@@ -495,6 +499,14 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
+ // register the generic local metadata provider for comic files
+ serviceCollection.AddSingleton<ComicProvider>();
+
+ // register the actual implementations of the local metadata provider for comic files
+ serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
+ serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
+ serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
+
serviceCollection.AddSingleton(NetManager);
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
@@ -539,6 +551,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
+ serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
serviceCollection.AddSingleton<NamingOptions>();
serviceCollection.AddSingleton<VideoListResolver>();
@@ -550,7 +563,8 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
- serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
+ serviceCollection.AddSingleton<ISearchManager, SearchManager>();
+ serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
@@ -709,6 +723,7 @@ namespace Emby.Server.Implementations
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
+ Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
}
/// <summary>
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index 0ede5665f9..295efd456c 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -4,12 +4,15 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections
private readonly ILibraryMonitor _iLibraryMonitor;
private readonly ILogger<CollectionManager> _logger;
private readonly IProviderManager _providerManager;
+ private readonly ILinkedChildrenService _linkedChildrenService;
private readonly ILocalizationManager _localizationManager;
private readonly IApplicationPaths _appPaths;
@@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections
/// <param name="iLibraryMonitor">The library monitor.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="providerManager">The provider manager.</param>
+ /// <param name="linkedChildrenService">The linked children service.</param>
public CollectionManager(
ILibraryManager libraryManager,
IApplicationPaths appPaths,
@@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections
IFileSystem fileSystem,
ILibraryMonitor iLibraryMonitor,
ILoggerFactory loggerFactory,
- IProviderManager providerManager)
+ IProviderManager providerManager,
+ ILinkedChildrenService linkedChildrenService)
{
_libraryManager = libraryManager;
_fileSystem = fileSystem;
_iLibraryMonitor = iLibraryMonitor;
_logger = loggerFactory.CreateLogger<CollectionManager>();
_providerManager = providerManager;
+ _linkedChildrenService = linkedChildrenService;
_localizationManager = localizationManager;
_appPaths = appPaths;
}
@@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
}
+ /// <inheritdoc />
+ public IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (itemId.IsEmpty())
+ {
+ return Enumerable.Empty<BoxSet>();
+ }
+
+ return _linkedChildrenService
+ .GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet)
+ .Select(parentId => _libraryManager.GetItemById<BoxSet>(parentId, user))
+ .OfType<BoxSet>();
+ }
+
private IEnumerable<BoxSet> GetCollections(User user)
{
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs
index 0b3c3bbd4f..4929568935 100644
--- a/Emby.Server.Implementations/Devices/DeviceId.cs
+++ b/Emby.Server.Implementations/Devices/DeviceId.cs
@@ -1,5 +1,3 @@
-#pragma warning disable CS1591
-
using System;
using System.Globalization;
using System.IO;
@@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Devices
{
+ /// <summary>
+ /// Provides the persistent unique identifier of this server installation.
+ /// </summary>
public class DeviceId
{
private readonly IApplicationPaths _appPaths;
@@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices
private string? _id;
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DeviceId"/> class.
+ /// </summary>
+ /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param>
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
{
_appPaths = appPaths;
_logger = logger;
}
+ /// <summary>
+ /// Gets the device id, loading it from disk or generating and persisting a new one if none exists.
+ /// </summary>
public string Value => _id ??= GetDeviceId();
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 321c7da1c4..8cbf42585d 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto
{
BaseItemKind.Person, [
BaseItemKind.Audio,
+ BaseItemKind.AudioBook,
+ BaseItemKind.Book,
BaseItemKind.Episode,
BaseItemKind.Movie,
BaseItemKind.LiveTvProgram,
@@ -167,9 +169,13 @@ namespace Emby.Server.Implementations.Dto
// Batch-fetch user data for all items
Dictionary<Guid, UserItemData>? userDataBatch = null;
+ IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null;
if (user is not null && options.EnableUserData)
{
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
+
+ // For items with alternate versions, the most recently played version drives resume.
+ resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
}
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
@@ -248,7 +254,8 @@ namespace Emby.Server.Implementations.Dto
allCollectionFolders,
childCountBatch,
playedCountBatch,
- artistsBatch);
+ artistsBatch,
+ resumeDataBatch?.GetValueOrDefault(item.Id));
if (item is LiveTvChannel tvChannel)
{
@@ -309,7 +316,8 @@ namespace Emby.Server.Implementations.Dto
List<Folder>? allCollectionFolders = null,
Dictionary<Guid, int>? childCountBatch = null,
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
- IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
+ IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
+ VersionResumeData? resumeData = null)
{
var dto = new BaseItemDto
{
@@ -353,7 +361,8 @@ namespace Emby.Server.Implementations.Dto
options,
userData,
childCountBatch,
- playedCountBatch);
+ playedCountBatch,
+ resumeData);
}
if (item is IHasMediaSources
@@ -369,7 +378,7 @@ namespace Emby.Server.Implementations.Dto
AttachStudios(dto, item);
}
- AttachBasicFields(dto, item, owner, options, artistsBatch);
+ AttachBasicFields(dto, item, owner, options, artistsBatch, user);
if (options.ContainsField(ItemFields.CanDelete))
{
@@ -538,7 +547,8 @@ namespace Emby.Server.Implementations.Dto
DtoOptions options,
UserItemData? userData = null,
Dictionary<Guid, int>? childCountBatch = null,
- Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
+ Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
+ VersionResumeData? resumeData = null)
{
if (item.IsFolder)
{
@@ -600,6 +610,9 @@ namespace Emby.Server.Implementations.Dto
// Use pre-fetched user data
dto.UserData = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
+
+ // For items with alternate versions, the most recently played version drives resume.
+ resumeData?.ApplyTo(dto.UserData);
}
else
{
@@ -943,7 +956,8 @@ namespace Emby.Server.Implementations.Dto
/// <param name="owner">The owner.</param>
/// <param name="options">The options.</param>
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
- private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
+ /// <param name="user">The user, for per-user values such as the accessible media source count.</param>
+ private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null)
{
if (options.ContainsField(ItemFields.DateCreated))
{
@@ -1257,7 +1271,12 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.MediaSourceCount))
{
- var mediaSourceCount = video.MediaSourceCount;
+ // Match the per-user filtering of the media sources: versions the user cannot
+ // access are not selectable, so they must not count towards the badge either.
+ var mediaSourceCount = user is null
+ || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
+ ? video.MediaSourceCount
+ : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
if (mediaSourceCount != 1)
{
dto.MediaSourceCount = mediaSourceCount;
@@ -1366,6 +1385,25 @@ namespace Emby.Server.Implementations.Dto
}
}
+ if (options.GetImageLimit(ImageType.Primary) > 0)
+ {
+ var episodeSeason = episode.Season;
+ var seasonPrimaryTag = episodeSeason is not null
+ ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
+ : null;
+
+ if (seasonPrimaryTag is not null)
+ {
+ dto.ParentPrimaryImageItemId = episodeSeason!.Id;
+ dto.ParentPrimaryImageTag = seasonPrimaryTag;
+ }
+ else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
+ {
+ dto.ParentPrimaryImageItemId = episodeSeries.Id;
+ dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
+ }
+ }
+
if (options.ContainsField(ItemFields.SeriesStudio))
{
episodeSeries ??= episode.Series;
@@ -1504,6 +1542,21 @@ namespace Emby.Server.Implementations.Dto
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
{
+ if (item is UserView { ViewType: CollectionType.playlists } playlistsView
+ && options.GetImageLimit(ImageType.Primary) > 0
+ && !playlistsView.DisplayParentId.IsEmpty())
+ {
+ var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId);
+ var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0);
+
+ if (displayParentPrimaryImage is not null)
+ {
+ dto.ImageTags?.Remove(ImageType.Primary);
+ dto.ParentPrimaryImageItemId = displayParent!.Id;
+ dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage);
+ }
+ }
+
if (!item.SupportsInheritedParentImages)
{
return;
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index e9bf3b93a7..dc7f972c13 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -127,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer
{
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
}
- catch (WebSocketException ex)
+ catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
{
+ // ObjectDisposedException/OperationCanceledException: the socket was torn
+ // down underneath us (e.g. by the keep-alive watchdog after the connection
+ // was declared lost). Fall through so Closed is still raised and the
+ // session can release this connection.
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
break;
}
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index 199407044b..ede9b27592 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO
}
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
{
- _logger.LogError(ex, "Failed to enumerate path {Path}", path);
+ _logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message);
return Enumerable.Empty<string>();
}
}
diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index 095934f896..b701e7eb6d 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common.Configuration;
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Images
{
@@ -28,38 +28,7 @@ namespace Emby.Server.Implementations.Images
{
var view = (CollectionFolder)item;
var viewType = view.CollectionType;
-
- BaseItemKind[] includeItemTypes;
-
- switch (viewType)
- {
- case CollectionType.movies:
- includeItemTypes = new[] { BaseItemKind.Movie };
- break;
- case CollectionType.tvshows:
- includeItemTypes = new[] { BaseItemKind.Series };
- break;
- case CollectionType.music:
- includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
- break;
- case CollectionType.musicvideos:
- includeItemTypes = new[] { BaseItemKind.MusicVideo };
- break;
- case CollectionType.books:
- includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
- break;
- case CollectionType.boxsets:
- includeItemTypes = new[] { BaseItemKind.BoxSet };
- break;
- case CollectionType.homevideos:
- case CollectionType.photos:
- includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
- break;
- default:
- includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
- break;
- }
-
+ var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
var recursive = viewType != CollectionType.playlists;
return view.GetItemList(new InternalItemsQuery
@@ -67,12 +36,9 @@ namespace Emby.Server.Implementations.Images
CollapseBoxSetItems = false,
Recursive = recursive,
DtoOptions = new DtoOptions(false),
- ImageTypes = new[] { ImageType.Primary },
+ ImageTypes = [ImageType.Primary],
Limit = 8,
- OrderBy = new[]
- {
- (ItemSortBy.Random, SortOrder.Ascending)
- },
+ OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
IncludeItemTypes = includeItemTypes
});
}
diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs
index 4ad0f999bf..2c18e56df7 100644
--- a/Emby.Server.Implementations/Library/ExternalDataManager.cs
+++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs
@@ -1,6 +1,5 @@
using System;
using System.IO;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Chapters;
@@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager
/// <inheritdoc/>
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
{
- var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
- var itemId = item.Id;
- if (validPaths.Count > 0)
- {
- foreach (var path in validPaths)
- {
- try
- {
- Directory.Delete(path, true);
- }
- catch (Exception ex)
- {
- _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
- }
- }
- }
+ DeleteExternalItemFiles(item);
+ var itemId = item.Id;
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
}
+
+ /// <inheritdoc/>
+ public void DeleteExternalItemFiles(BaseItem item)
+ {
+ foreach (var path in _pathManager.GetExtractedDataPaths(item))
+ {
+ if (!Directory.Exists(path))
+ {
+ continue;
+ }
+
+ try
+ {
+ Directory.Delete(path, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
+ }
+ }
+ }
}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 30ff1bd333..3691f4e19d 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -89,6 +89,7 @@ namespace Emby.Server.Implementations.Library
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository;
+ private readonly Lazy<IExternalDataManager> _externalDataManagerFactory;
/// <summary>
/// The _root folder sync lock.
@@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library
/// <param name="pathManager">The path manager.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
/// <param name="mediaStreamRepository">The media stream repository.</param>
+ /// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
public LibraryManager(
IServerApplicationHost appHost,
ILoggerFactory loggerFactory,
@@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library
IPeopleRepository peopleRepository,
IPathManager pathManager,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
- IMediaStreamRepository mediaStreamRepository)
+ IMediaStreamRepository mediaStreamRepository,
+ Lazy<IExternalDataManager> externalDataManagerFactory)
{
_appHost = appHost;
_logger = loggerFactory.CreateLogger<LibraryManager>();
@@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
_mediaStreamRepository = mediaStreamRepository;
+ _externalDataManagerFactory = externalDataManagerFactory;
RecordConfigurationValues(_configurationManager.Configuration);
}
@@ -396,6 +400,12 @@ namespace Emby.Server.Implementations.Library
}
}
+ var externalDataManager = _externalDataManagerFactory.Value;
+ foreach (var (item, _, _) in pathMaps)
+ {
+ externalDataManager.DeleteExternalItemFiles(item);
+ }
+
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
}
@@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library
item.SetParent(null);
+ var externalDataManager = _externalDataManagerFactory.Value;
+ externalDataManager.DeleteExternalItemFiles(item);
+ foreach (var child in children)
+ {
+ externalDataManager.DeleteExternalItemFiles(child);
+ }
+
_persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]);
_cache.TryRemove(item.Id, out _);
foreach (var child in children)
@@ -1987,7 +2004,8 @@ namespace Emby.Server.Implementations.Library
query.TopParentIds.Length == 0 &&
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
- query.ItemIds.Length == 0)
+ query.ItemIds.Length == 0 &&
+ query.OwnerIds.Length == 0)
{
var userViews = UserViewManager.GetUserViews(new UserViewQuery
{
@@ -2432,8 +2450,14 @@ namespace Emby.Server.Implementations.Library
var outdated = forceUpdate
? item.ImageInfos.Where(i => i.Path is not null).ToArray()
: item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
- // Skip image processing if current or live tv source
- if (outdated.Length == 0 || item.SourceType != SourceType.Library)
+
+ var parentItem = item.GetParent();
+ var isLiveTvShow = item.SourceType != SourceType.Library &&
+ parentItem is not null &&
+ parentItem.SourceType != SourceType.Library; // not a channel
+
+ // Skip image processing if current or live tv show
+ if (outdated.Length == 0 || isLiveTvShow)
{
RegisterItem(item);
return;
@@ -3394,6 +3418,12 @@ namespace Emby.Server.Implementations.Library
return _peopleRepository.GetPeopleNames(query);
}
+ /// <inheritdoc/>
+ public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
+ {
+ return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
+ }
+
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
{
UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult();
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index fdb4c7328b..97e00177b6 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -24,6 +24,7 @@ using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
@@ -127,6 +128,11 @@ namespace Emby.Server.Implementations.Library
return true;
}
+ if (stream.IsVobSubSubtitleStream)
+ {
+ return true;
+ }
+
return false;
}
@@ -171,6 +177,7 @@ namespace Emby.Server.Implementations.Library
public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
{
var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
+ ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
// If file is strm or main media stream is missing, force a metadata refresh with remote probing
if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder
@@ -187,6 +194,7 @@ namespace Emby.Server.Implementations.Library
cancellationToken).ConfigureAwait(false);
mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
+ ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
}
var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false);
@@ -221,7 +229,11 @@ namespace Emby.Server.Implementations.Library
list.Add(source);
}
- return SortMediaSources(list).ToArray();
+ var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
+ ? topSourceId
+ : item.Id;
+
+ return SortMediaSources(list, preferredId).ToArray();
}
/// <inheritdoc />>
@@ -319,6 +331,28 @@ namespace Emby.Server.Implementations.Library
}
}
+ /// <summary>
+ /// Resolves symlinked file paths on the supplied sources to the real on-disk target.
+ /// Skipped when <paramref name="enablePathSubstitution"/> is set because the path may
+ /// already have been rewritten to a UNC/URL meant for the client to consume directly.
+ /// </summary>
+ private static void ResolveSymlinkPaths(IReadOnlyList<MediaSourceInfo> sources, bool enablePathSubstitution)
+ {
+ if (enablePathSubstitution)
+ {
+ return;
+ }
+
+ foreach (var source in sources)
+ {
+ if (source.Protocol == MediaProtocol.File
+ && FileSystemHelper.ResolveLinkTarget(source.Path, returnFinalTarget: true) is { Exists: true } target)
+ {
+ source.Path = target.FullName;
+ }
+ }
+ }
+
private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource)
{
var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter;
@@ -356,6 +390,12 @@ namespace Emby.Server.Implementations.Library
if (user is not null)
{
+ sources = sources
+ .Where(source => !Guid.TryParse(source.Id, out var sourceId)
+ || sourceId.Equals(item.Id)
+ || _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
+ .ToArray();
+
foreach (var source in sources)
{
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -370,6 +410,59 @@ namespace Emby.Server.Implementations.Library
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
}
}
+
+ sources = SetAlternateVersionResumeStates(item, sources, user);
+ }
+
+ return sources;
+ }
+
+ /// <summary>
+ /// When the queried item is a primary, moves the most recently played version to the front so
+ /// that resuming without an explicit source selection plays the version that was last watched.
+ /// A directly queried alternate version keeps its own source first. Per-user playback position
+ /// is not surfaced on the source itself; it is carried by each version's own UserData.
+ /// </summary>
+ /// <param name="item">The queried item.</param>
+ /// <param name="sources">The item's media sources.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>The media sources, reordered when a version drives resume.</returns>
+ private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user)
+ {
+ // For a video, multiple sources means alternate versions.
+ if (item is not Video video || sources.Count < 2)
+ {
+ return sources;
+ }
+
+ var versions = video.GetAllVersions();
+ if (versions.Count < 2)
+ {
+ return sources;
+ }
+
+ var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
+ var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase);
+ foreach (var version in versions)
+ {
+ if (userDataByVersion.TryGetValue(version.Id, out var data))
+ {
+ dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data;
+ }
+ }
+
+ // Reorder only for a resumable (in-progress) version;
+ // a completed version has no position to resume, so it must not be pulled to the front here.
+ var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ sources,
+ source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null,
+ data => data.PlaybackPositionTicks > 0);
+
+ if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
+ {
+ var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource };
+ reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource)));
+ return reordered;
}
return sources;
@@ -440,10 +533,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 +587,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);
@@ -524,24 +603,32 @@ namespace Emby.Server.Implementations.Library
}
}
- private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
+ private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
{
- return sources.OrderBy(i =>
- {
- if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ // The source belonging to the queried item sorts first so it stays the default that gets played.
+ var preferredId = preferredItemId.IsEmpty()
+ ? null
+ : preferredItemId.ToString("N", CultureInfo.InvariantCulture);
+
+ return sources
+ .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i =>
{
- return 0;
- }
+ if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ {
+ return 0;
+ }
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i =>
- {
- var stream = i.VideoStream;
+ return 1;
+ })
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i =>
+ {
+ var stream = i.VideoStream;
- return stream?.Width ?? 0;
- })
- .Where(i => i.Type != MediaSourceType.Placeholder);
+ return stream?.Width ?? 0;
+ })
+ .Where(i => i.Type != MediaSourceType.Placeholder);
}
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index ef5edb9afa..fad948ad97 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -121,7 +121,11 @@ public class PathManager : IPathManager
}
paths.Add(GetTrickplayDirectory(item, false));
- paths.Add(GetTrickplayDirectory(item, true));
+ if (!string.IsNullOrEmpty(item.Path))
+ {
+ paths.Add(GetTrickplayDirectory(item, true));
+ }
+
paths.Add(GetChapterImageFolderPath(item));
return paths;
diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
index 1e885aad6e..7d51a0daa0 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
@@ -1,5 +1,3 @@
-#nullable disable
-
#pragma warning disable CS1591
using System;
@@ -18,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
{
private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" };
- protected override Book Resolve(ItemResolveArgs args)
+ protected override Book? Resolve(ItemResolveArgs args)
{
var collectionType = args.GetCollectionType();
@@ -47,13 +45,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
Path = args.Path,
Name = result.Name ?? string.Empty,
IndexNumber = result.Index,
+ ParentIndexNumber = result.ParentIndex,
ProductionYear = result.Year,
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
IsInMixedFolder = true,
};
}
- private Book GetBook(ItemResolveArgs args)
+ private Book? GetBook(ItemResolveArgs args)
{
var bookFiles = args.FileSystemChildren.Where(f =>
{
@@ -78,6 +77,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
Path = bookFiles[0].FullName,
Name = result.Name ?? string.Empty,
IndexNumber = result.Index,
+ ParentIndexNumber = result.ParentIndex,
ProductionYear = result.Year,
SeriesName = result.SeriesName ?? string.Empty,
};
diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
index 14798dda65..74c1f69616 100644
--- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
@@ -1,6 +1,7 @@
#nullable disable
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
@@ -46,7 +47,16 @@ namespace Emby.Server.Implementations.Library.Resolvers
}
// It's a directory-based playlist if the directory contains a playlist file
- var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
+ IEnumerable<string> filePaths;
+ try
+ {
+ filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
{
return new Playlist
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
index 769d721665..8d40eab006 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
@@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return null;
}
+ if (args.Parent is not null && args.Parent.IsRoot)
+ {
+ return null;
+ }
+
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
var collectionType = args.GetCollectionType();
@@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return new Series
{
Path = args.Path,
- Name = seriesInfo.Name
+ Name = seriesInfo.Name,
+ ProductionYear = seriesInfo.Year
};
}
}
diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs
new file mode 100644
index 0000000000..a5be3f07bd
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs
@@ -0,0 +1,458 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Search;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Library.Search;
+
+/// <summary>
+/// Manages search providers and orchestrates search operations.
+/// </summary>
+public class SearchManager : ISearchManager
+{
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IItemQueryHelpers _queryHelpers;
+ private readonly ILogger<SearchManager> _logger;
+ private IExternalSearchProvider[] _externalProviders = [];
+ private IInternalSearchProvider[] _internalProviders = [];
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SearchManager"/> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="dbProvider">The database context factory.</param>
+ /// <param name="queryHelpers">The shared item query helpers.</param>
+ /// <param name="logger">The logger.</param>
+ public SearchManager(
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IItemQueryHelpers queryHelpers,
+ ILogger<SearchManager> logger)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _dbProvider = dbProvider;
+ _queryHelpers = queryHelpers;
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public void AddParts(IEnumerable<ISearchProvider> providers)
+ {
+ var allProviders = providers.OrderBy(p => p.Priority).ToArray();
+
+ _externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
+ _internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
+
+ _logger.LogInformation(
+ "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}",
+ _externalProviders.Length,
+ string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
+ string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
+ }
+
+ /// <inheritdoc/>
+ public IReadOnlyList<ISearchProvider> GetProviders()
+ {
+ return [.. _externalProviders, .. _internalProviders];
+ }
+
+ /// <inheritdoc/>
+ public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
+ SearchProviderQuery query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
+
+ var searchTerm = query.SearchTerm.Trim().RemoveDiacritics();
+
+ var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken);
+ var internalTask = _internalProviders.Length > 0
+ ? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken)
+ : Task.FromResult<IReadOnlyList<SearchResult>>([]);
+
+ await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
+
+ var externalResults = await externalTask.ConfigureAwait(false);
+ var fromExternal = externalResults.Count > 0;
+ IReadOnlyList<SearchResult> results;
+ if (fromExternal)
+ {
+ results = externalResults;
+ }
+ else
+ {
+ results = await internalTask.ConfigureAwait(false);
+ if (_internalProviders.Length > 0)
+ {
+ _logger.LogDebug("No results from external providers, using internal provider results");
+ }
+ }
+
+ // Internal providers apply user-access filtering inline in their queries. External
+ // providers don't know about user permissions, so they may return IDs from hidden
+ // libraries or items the user is otherwise blocked from. Run the post-filter only
+ // when results came from externals to close that gap. The Items controller's second
+ // roundtrip via folder.GetItems applies most of these again, but it does not restrict
+ // by TopParentIds when ItemIds is set.
+ if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
+ {
+ var user = _userManager.GetUserById(query.UserId.Value);
+ if (user is not null)
+ {
+ results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return results;
+ }
+
+ private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
+ IReadOnlyList<SearchResult> candidates,
+ User user,
+ CancellationToken cancellationToken)
+ {
+ // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
+ // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
+ // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
+ var accessFilter = new InternalItemsQuery(user);
+ _libraryManager.ConfigureUserAccess(accessFilter, user);
+
+ Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var baseQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(candidateIds, e => e.Id);
+
+ baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
+
+ var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
+ if (allowedCount == candidates.Count)
+ {
+ return candidates;
+ }
+
+ var allowedIds = await baseQuery
+ .Select(e => e.Id)
+ .ToHashSetAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
+ if (filtered.Count < candidates.Count)
+ {
+ _logger.LogDebug(
+ "Dropped {Dropped} of {Total} search candidates due to user access filtering",
+ candidates.Count - filtered.Count,
+ candidates.Count);
+ }
+
+ return filtered;
+ }
+ }
+
+ /// <inheritdoc/>
+ public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
+
+ var providerQuery = BuildProviderQuery(query);
+ var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
+ if (candidates.Count == 0)
+ {
+ return new QueryResult<SearchHintInfo>();
+ }
+
+ var candidateScores = BuildScoreLookup(candidates);
+ var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId);
+
+ var excludeItemTypes = BuildExcludeItemTypes(query);
+ var includeItemTypes = BuildIncludeItemTypes(query);
+
+ var internalQuery = new InternalItemsQuery(user)
+ {
+ ItemIds = candidateScores.Keys.ToArray(),
+ ExcludeItemTypes = excludeItemTypes.ToArray(),
+ IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
+ MediaTypes = query.MediaTypes.ToArray(),
+ IncludeItemsByName = !query.ParentId.HasValue,
+ ParentId = query.ParentId ?? Guid.Empty,
+ Recursive = true,
+ IsKids = query.IsKids,
+ IsMovie = query.IsMovie,
+ IsNews = query.IsNews,
+ IsSeries = query.IsSeries,
+ IsSports = query.IsSports,
+ DtoOptions = new DtoOptions
+ {
+ Fields =
+ [
+ ItemFields.AirTime,
+ ItemFields.DateCreated,
+ ItemFields.ChannelInfo,
+ ItemFields.ParentId
+ ]
+ }
+ };
+
+ // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
+ // rather than being stored as regular library items. They require special handling:
+ // 1. Convert ParentId to AncestorIds (to filter by library folder)
+ // 2. Set IncludeItemsByName = true (to include these virtual items in results)
+ // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
+ // 4. Use GetAllArtists() instead of GetItemList() to query the artist index
+ IReadOnlyList<BaseItem> items;
+ if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
+ {
+ if (!internalQuery.ParentId.IsEmpty())
+ {
+ internalQuery.AncestorIds = [internalQuery.ParentId];
+ internalQuery.ParentId = Guid.Empty;
+ }
+
+ internalQuery.IncludeItemsByName = true;
+ internalQuery.IncludeItemTypes = [];
+ items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
+ }
+ else
+ {
+ items = _libraryManager.GetItemList(internalQuery);
+ }
+
+ var orderedResults = items
+ .Select(item => new SearchHintInfo { Item = item })
+ .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
+ .ToList();
+
+ var totalCount = orderedResults.Count;
+
+ if (query.StartIndex.HasValue)
+ {
+ orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
+ }
+
+ if (query.Limit.HasValue)
+ {
+ orderedResults = orderedResults.Take(query.Limit.Value).ToList();
+ }
+
+ return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
+ }
+
+ private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
+ IEnumerable<ISearchProvider> providers,
+ SearchProviderQuery providerQuery,
+ string searchTerm,
+ CancellationToken cancellationToken)
+ {
+ var requestedLimit = providerQuery.Limit ?? 100;
+ var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
+ if (applicable.Length == 0)
+ {
+ return [];
+ }
+
+ var perProvider = await Task.WhenAll(
+ applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken)))
+ .ConfigureAwait(false);
+
+ var bestScores = new Dictionary<Guid, float>();
+ foreach (var providerResults in perProvider)
+ {
+ foreach (var result in providerResults)
+ {
+ UpdateBestScore(bestScores, result);
+ }
+ }
+
+ return bestScores
+ .Select(kvp => new SearchResult(kvp.Key, kvp.Value))
+ .OrderByDescending(r => r.Score)
+ .Take(requestedLimit)
+ .ToList();
+ }
+
+ private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
+ ISearchProvider provider,
+ SearchProviderQuery providerQuery,
+ string searchTerm,
+ int requestedLimit,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var results = provider is IExternalSearchProvider externalProvider
+ ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false)
+ : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
+
+ _logger.LogDebug(
+ "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
+ provider.Name,
+ results.Count,
+ searchTerm);
+ return results;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm);
+ return [];
+ }
+ }
+
+ private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
+ IExternalSearchProvider provider,
+ SearchProviderQuery providerQuery,
+ int requestedLimit,
+ CancellationToken cancellationToken)
+ {
+ var results = new List<SearchResult>();
+ await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
+ {
+ results.Add(result);
+ if (results.Count >= requestedLimit)
+ {
+ break;
+ }
+ }
+
+ return results;
+ }
+
+ private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
+ {
+ if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
+ {
+ bestScores[result.ItemId] = result.Score;
+ }
+ }
+
+ private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
+ {
+ var lookup = new Dictionary<Guid, float>(results.Count);
+ foreach (var result in results)
+ {
+ lookup[result.ItemId] = result.Score;
+ }
+
+ return lookup;
+ }
+
+ private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
+ {
+ var excludeItemTypes = BuildExcludeItemTypes(query);
+ var includeItemTypes = BuildIncludeItemTypes(query);
+
+ // Remove any excluded types from includes
+ if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
+ {
+ includeItemTypes.RemoveAll(excludeItemTypes.Contains);
+ }
+
+ return new SearchProviderQuery
+ {
+ SearchTerm = query.SearchTerm,
+ UserId = query.UserId.IsEmpty() ? null : query.UserId,
+ IncludeItemTypes = includeItemTypes.ToArray(),
+ ExcludeItemTypes = excludeItemTypes.ToArray(),
+ MediaTypes = query.MediaTypes.ToArray(),
+ Limit = query.Limit,
+ ParentId = query.ParentId
+ };
+ }
+
+ private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
+ {
+ var excludeItemTypes = query.ExcludeItemTypes.ToList();
+
+ excludeItemTypes.Add(BaseItemKind.Year);
+ excludeItemTypes.Add(BaseItemKind.Folder);
+ excludeItemTypes.Add(BaseItemKind.CollectionFolder);
+
+ if (!query.IncludeGenres)
+ {
+ AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
+ AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
+ }
+
+ if (!query.IncludePeople)
+ {
+ AddIfMissing(excludeItemTypes, BaseItemKind.Person);
+ }
+
+ if (!query.IncludeStudios)
+ {
+ AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
+ }
+
+ if (!query.IncludeArtists)
+ {
+ AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
+ }
+
+ return excludeItemTypes;
+ }
+
+ private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
+ {
+ var includeItemTypes = query.IncludeItemTypes.ToList();
+ if (query.IncludeMedia)
+ {
+ return includeItemTypes;
+ }
+
+ if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
+ {
+ AddIfMissing(includeItemTypes, BaseItemKind.Genre);
+ AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
+ }
+
+ if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
+ {
+ AddIfMissing(includeItemTypes, BaseItemKind.Person);
+ }
+
+ if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
+ {
+ AddIfMissing(includeItemTypes, BaseItemKind.Studio);
+ }
+
+ if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
+ {
+ AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
+ }
+
+ return includeItemTypes;
+ }
+
+ private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
+ => list.Count == 0 || list.Contains(value);
+
+ private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
+ {
+ if (!list.Contains(value))
+ {
+ list.Add(value);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
new file mode 100644
index 0000000000..bc766f1c8c
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
@@ -0,0 +1,230 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+
+namespace Emby.Server.Implementations.Library.Search;
+
+/// <summary>
+/// Built-in SQL-based search provider that queries the library database directly.
+/// </summary>
+public class SqlSearchProvider : IInternalSearchProvider
+{
+ private const int DefaultSearchLimit = 100;
+ private const float ExactMatchScore = 100f;
+ private const float PrefixMatchScore = 80f;
+ private const float WordPrefixMatchScore = 75f;
+ private const float ContainsMatchScore = 50f;
+
+ private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
+
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IItemTypeLookup _itemTypeLookup;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IItemQueryHelpers _queryHelpers;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
+ /// </summary>
+ /// <param name="dbProvider">The database context factory.</param>
+ /// <param name="itemTypeLookup">The item type lookup.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="queryHelpers">The shared item query helpers.</param>
+ public SqlSearchProvider(
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IItemTypeLookup itemTypeLookup,
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IItemQueryHelpers queryHelpers)
+ {
+ _dbProvider = dbProvider;
+ _itemTypeLookup = itemTypeLookup;
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _queryHelpers = queryHelpers;
+ }
+
+ /// <inheritdoc/>
+ public string Name => "Database";
+
+ /// <inheritdoc/>
+ public MetadataPluginType Type => MetadataPluginType.SearchProvider;
+
+ /// <inheritdoc/>
+ public int Priority => 100; // Low priority - runs as fallback
+
+ /// <inheritdoc/>
+ public bool CanSearch(SearchProviderQuery query)
+ {
+ // SQL search can always handle any query
+ return true;
+ }
+
+ /// <inheritdoc/>
+ public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
+
+ var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics();
+ if (string.IsNullOrEmpty(rawSearchTerm))
+ {
+ return [];
+ }
+
+ var cleanSearchTerm = rawSearchTerm.GetCleanValue();
+ if (string.IsNullOrEmpty(cleanSearchTerm))
+ {
+ return [];
+ }
+
+ var cleanPrefix = cleanSearchTerm + " ";
+ // OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName,
+ // so match it via a case-insensitive LIKE rather than a per-row case conversion
+ // that may not translate to SQL on every provider.
+ var likeOriginal = $"%{rawSearchTerm}%";
+ var limit = query.Limit ?? DefaultSearchLimit;
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ // Lightweight projection: select only what's needed to score and identify items.
+ var dbQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .Where(e => e.Id != _placeholderId)
+ .Where(e => !e.IsVirtualItem)
+ .Where(e => e.CleanName!.Contains(cleanSearchTerm)
+ || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
+
+ dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
+ dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
+ dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
+ dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
+
+ // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
+ // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
+ // directly without any per-row case conversion. Items that match only via
+ // OriginalTitle fall through to the Contains tier.
+ // Tie-break by Id for deterministic ordering so the explicit OrderBy + Take
+ // satisfies EF Core's row-limiting-with-OrderBy requirement.
+ var scored = dbQuery.Select(e => new
+ {
+ e.Id,
+ Score =
+ (e.CleanName == cleanSearchTerm) ? ExactMatchScore
+ : e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore
+ : e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore
+ : ContainsMatchScore
+ });
+
+ return await scored
+ .OrderByDescending(x => x.Score)
+ .ThenBy(x => x.Id)
+ .Take(limit)
+ .Select(x => new SearchResult(x.Id, x.Score))
+ .ToArrayAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ private IQueryable<BaseItemEntity> ApplyTypeFilter(
+ IQueryable<BaseItemEntity> query,
+ BaseItemKind[] includeItemTypes,
+ BaseItemKind[] excludeItemTypes)
+ {
+ if (includeItemTypes.Length > 0)
+ {
+ var includeTypeNames = MapKindsToTypeNames(includeItemTypes);
+ if (includeTypeNames.Count > 0)
+ {
+ query = query.Where(e => includeTypeNames.Contains(e.Type));
+ }
+ }
+ else if (excludeItemTypes.Length > 0)
+ {
+ var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes);
+ if (excludeTypeNames.Count > 0)
+ {
+ query = query.Where(e => !excludeTypeNames.Contains(e.Type));
+ }
+ }
+
+ return query;
+ }
+
+ private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter(
+ IQueryable<BaseItemEntity> query,
+ MediaType[] mediaTypes)
+ {
+ if (mediaTypes.Length == 0)
+ {
+ return query;
+ }
+
+ var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray();
+ return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType));
+ }
+
+ private static IQueryable<BaseItemEntity> ApplyParentFilter(
+ IQueryable<BaseItemEntity> query,
+ Guid? parentId)
+ {
+ if (!parentId.HasValue || parentId.Value.IsEmpty())
+ {
+ return query;
+ }
+
+ var pid = parentId.Value;
+ return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
+ }
+
+ private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
+ JellyfinDbContext dbContext,
+ IQueryable<BaseItemEntity> query,
+ Guid? userId)
+ {
+ if (!userId.HasValue || userId.Value.IsEmpty())
+ {
+ return query;
+ }
+
+ var user = _userManager.GetUserById(userId.Value);
+ if (user is null)
+ {
+ return query;
+ }
+
+ var accessFilter = new InternalItemsQuery(user);
+ _libraryManager.ConfigureUserAccess(accessFilter, user);
+ return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
+ }
+
+ private List<string> MapKindsToTypeNames(BaseItemKind[] kinds)
+ {
+ var list = new List<string>(kinds.Length);
+ foreach (var kind in kinds)
+ {
+ if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null)
+ {
+ list.Add(name);
+ }
+ }
+
+ return list;
+ }
+}
diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs
deleted file mode 100644
index c682118597..0000000000
--- a/Emby.Server.Implementations/Library/SearchEngine.cs
+++ /dev/null
@@ -1,200 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Jellyfin.Data.Enums;
-using Jellyfin.Database.Implementations.Entities;
-using Jellyfin.Database.Implementations.Enums;
-using Jellyfin.Extensions;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Search;
-
-namespace Emby.Server.Implementations.Library
-{
- public class SearchEngine : ISearchEngine
- {
- private readonly ILibraryManager _libraryManager;
- private readonly IUserManager _userManager;
-
- public SearchEngine(ILibraryManager libraryManager, IUserManager userManager)
- {
- _libraryManager = libraryManager;
- _userManager = userManager;
- }
-
- public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query)
- {
- User? user = null;
- if (!query.UserId.IsEmpty())
- {
- user = _userManager.GetUserById(query.UserId);
- }
-
- var results = GetSearchHints(query, user);
- var totalRecordCount = results.Count;
-
- if (query.StartIndex.HasValue)
- {
- results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
- }
-
- if (query.Limit.HasValue && query.Limit.Value > 0)
- {
- results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count));
- }
-
- return new QueryResult<SearchHintInfo>(
- query.StartIndex,
- totalRecordCount,
- results);
- }
-
- private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
- {
- if (!list.Contains(value))
- {
- list.Add(value);
- }
- }
-
- /// <summary>
- /// Gets the search hints.
- /// </summary>
- /// <param name="query">The query.</param>
- /// <param name="user">The user.</param>
- /// <returns>IEnumerable{SearchHintResult}.</returns>
- /// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception>
- private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user)
- {
- var searchTerm = query.SearchTerm;
-
- ArgumentException.ThrowIfNullOrEmpty(searchTerm);
-
- searchTerm = searchTerm.Trim().RemoveDiacritics();
-
- var excludeItemTypes = query.ExcludeItemTypes.ToList();
- var includeItemTypes = query.IncludeItemTypes.ToList();
-
- excludeItemTypes.Add(BaseItemKind.Year);
- excludeItemTypes.Add(BaseItemKind.Folder);
-
- if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre)))
- {
- if (!query.IncludeMedia)
- {
- AddIfMissing(includeItemTypes, BaseItemKind.Genre);
- AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
- }
- }
- else
- {
- AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
- AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
- }
-
- if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person)))
- {
- if (!query.IncludeMedia)
- {
- AddIfMissing(includeItemTypes, BaseItemKind.Person);
- }
- }
- else
- {
- AddIfMissing(excludeItemTypes, BaseItemKind.Person);
- }
-
- if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio)))
- {
- if (!query.IncludeMedia)
- {
- AddIfMissing(includeItemTypes, BaseItemKind.Studio);
- }
- }
- else
- {
- AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
- }
-
- if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist)))
- {
- if (!query.IncludeMedia)
- {
- AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
- }
- }
- else
- {
- AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
- }
-
- AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder);
- AddIfMissing(excludeItemTypes, BaseItemKind.Folder);
- var mediaTypes = query.MediaTypes.ToList();
-
- if (includeItemTypes.Count > 0)
- {
- excludeItemTypes.Clear();
- mediaTypes.Clear();
- }
-
- var searchQuery = new InternalItemsQuery(user)
- {
- SearchTerm = searchTerm,
- ExcludeItemTypes = excludeItemTypes.ToArray(),
- IncludeItemTypes = includeItemTypes.ToArray(),
- Limit = query.Limit,
- IncludeItemsByName = !query.ParentId.HasValue,
- ParentId = query.ParentId ?? Guid.Empty,
- OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
- Recursive = true,
-
- IsKids = query.IsKids,
- IsMovie = query.IsMovie,
- IsNews = query.IsNews,
- IsSeries = query.IsSeries,
- IsSports = query.IsSports,
- MediaTypes = mediaTypes.ToArray(),
-
- DtoOptions = new DtoOptions
- {
- Fields = new ItemFields[]
- {
- ItemFields.AirTime,
- ItemFields.DateCreated,
- ItemFields.ChannelInfo,
- ItemFields.ParentId
- }
- }
- };
-
- IReadOnlyList<BaseItem> mediaItems;
-
- if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
- {
- if (!searchQuery.ParentId.IsEmpty())
- {
- searchQuery.AncestorIds = [searchQuery.ParentId];
- searchQuery.ParentId = Guid.Empty;
- }
-
- searchQuery.IncludeItemsByName = true;
- searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>();
- mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList();
- }
- else
- {
- mediaItems = _libraryManager.GetItemList(searchQuery);
- }
-
- return mediaItems.Select(i => new SearchHintInfo
- {
- Item = i
- }).ToList();
- }
- }
-}
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
index 93aa0574c0..57d1f7c770 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -1,37 +1,77 @@
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;
+ private readonly ILibraryManager _libraryManager;
/// <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>
+ /// <param name="libraryManager">The library manager.</param>
public MovieSimilarItemsProvider(
- ILibraryManager libraryManager,
- IServerConfigurationManager serverConfigurationManager)
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IItemQueryHelpers queryHelpers,
+ IServerConfigurationManager serverConfigurationManager,
+ ILibraryManager libraryManager)
{
- _libraryManager = libraryManager;
+ _dbProvider = dbProvider;
+ _queryHelpers = queryHelpers;
_serverConfigurationManager = serverConfigurationManager;
+ _libraryManager = libraryManager;
}
/// <inheritdoc/>
@@ -41,15 +81,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 +105,238 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
_ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
};
- private IReadOnlyList<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query)
+ /// <inheritdoc/>
+ public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
+ IReadOnlyList<BaseItemDto> sourceItems,
+ SimilarItemsQuery query,
+ CancellationToken cancellationToken)
{
var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
-
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
includeItemTypes.Add(BaseItemKind.Trailer);
includeItemTypes.Add(BaseItemKind.LiveTvProgram);
}
- var internalQuery = new InternalItemsQuery(query.User)
+ var limit = query.Limit ?? 50;
+ var dtoOptions = query.DtoOptions ?? new DtoOptions();
+
+ if (sourceItems.Count > MaxBatchSourceItems)
{
- Genres = item.Genres,
- Tags = item.Tags,
- Limit = query.Limit,
- DtoOptions = query.DtoOptions ?? new DtoOptions(),
- ExcludeItemIds = [.. query.ExcludeItemIds],
- IncludeItemTypes = [.. includeItemTypes],
- EnableGroupByMetadataKey = true,
- EnableTotalRecordCount = false,
- OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
- };
+ sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
+ }
+
+ var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ // Phase 1: Score all candidates per source item
+ var sourceIds = sourceItems.Select(i => i.Id).ToList();
+ var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false);
+
+ var allCandidateIds = new HashSet<Guid>();
+ foreach (var (_, scores) in perSourceScores)
+ {
+ allCandidateIds.UnionWith(
+ scores.OrderByDescending(kvp => kvp.Value)
+ .Take(limit * 3)
+ .Select(kvp => kvp.Key));
+ }
+
+ var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
+ if (allCandidateIds.Count == 0)
+ {
+ return result;
+ }
+
+ // Phase 2: One access filter for all candidates
+ var filter = new InternalItemsQuery(query.User)
+ {
+ IncludeItemTypes = [.. includeItemTypes],
+ ExcludeItemIds = [.. query.ExcludeItemIds],
+ DtoOptions = dtoOptions,
+ EnableGroupByMetadataKey = true,
+ EnableTotalRecordCount = false,
+ IsMovie = true,
+ IsPlayed = false
+ };
+
+ if (query.User is not null)
+ {
+ _libraryManager.ConfigureUserAccess(filter, query.User);
+ }
+
+ _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);
- return _libraryManager.GetItemList(internalQuery);
+ // 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;
+ }
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
index b56779cf3f..d923cff07e 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
@@ -8,12 +8,16 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions.Json;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
@@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly IFileSystem _fileSystem;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
private ISimilarItemsProvider[] _similarItemsProviders = [];
/// <summary>
@@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager
/// <param name="appPaths">The server application paths.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="fileSystem">The file system.</param>
+ /// <param name="serverConfigurationManager">The server configuration manager.</param>
public SimilarItemsManager(
ILogger<SimilarItemsManager> logger,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
- IFileSystem fileSystem)
+ IFileSystem fileSystem,
+ IServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_appPaths = appPaths;
_libraryManager = libraryManager;
_fileSystem = fileSystem;
+ _serverConfigurationManager = serverConfigurationManager;
}
/// <inheritdoc/>
@@ -117,6 +125,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var allResults = new List<(BaseItem Item, float Score)>();
var excludeIds = new HashSet<Guid> { item.Id };
+ var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() };
foreach (var (providerOrder, provider) in orderedProviders.Index())
{
if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
@@ -141,7 +150,9 @@ public class SimilarItemsManager : ISimilarItemsManager
foreach (var (position, resultItem) in items.Index())
{
- if (excludeIds.Add(resultItem.Id))
+ var isNewId = excludeIds.Add(resultItem.Id);
+ var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey());
+ if (isNewId && isNewKey)
{
var score = CalculateScore(null, providerOrder, position);
allResults.Add((resultItem, score));
@@ -155,7 +166,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false);
if (cachedReferences is not null)
{
- var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
continue;
}
@@ -183,7 +194,7 @@ public class SimilarItemsManager : ISimilarItemsManager
if (pendingBatch.Count >= BatchSize)
{
- var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
remaining -= resolvedItems.Count;
pendingBatch.Clear();
@@ -198,7 +209,7 @@ public class SimilarItemsManager : ISimilarItemsManager
// Resolve any remaining references in the last partial batch
if (pendingBatch.Count > 0)
{
- var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
}
@@ -225,20 +236,230 @@ 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)
+ .Values
+ .SelectMany(names => names)
+ .Distinct()
+ .ToArray();
+ }
+
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
IReadOnlyList<SimilarItemReference> references,
int providerOrder,
User? user,
DtoOptions dtoOptions,
BaseItemKind itemKind,
- HashSet<Guid> excludeIds)
+ HashSet<Guid> excludeIds,
+ HashSet<string> excludeKeys)
{
if (references.Count == 0)
{
return [];
}
- var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>();
+ var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase);
var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance);
foreach (var (position, match) in references.Index())
@@ -269,7 +490,13 @@ public class SimilarItemsManager : ISimilarItemsManager
foreach (var item in items)
{
- if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id))
+ if (excludeIds.Contains(item.Id))
+ {
+ continue;
+ }
+
+ var presentationKey = item.GetPresentationUniqueKey();
+ if (excludeKeys.Contains(presentationKey))
{
continue;
}
@@ -279,10 +506,9 @@ public class SimilarItemsManager : ISimilarItemsManager
if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo))
{
var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
- if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score)
+ if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
{
- excludeIds.Add(item.Id);
- resolvedById[item.Id] = (item, score);
+ resolvedByKey[presentationKey] = (item, score);
}
break;
@@ -290,7 +516,13 @@ public class SimilarItemsManager : ISimilarItemsManager
}
}
- return [.. resolvedById.Values];
+ foreach (var (key, entry) in resolvedByKey)
+ {
+ excludeIds.Add(entry.Item.Id);
+ excludeKeys.Add(key);
+ }
+
+ return [.. resolvedByKey.Values];
}
private static float CalculateScore(float? matchScore, int providerOrder, int position)
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 1281f1587f..40cd2bb69c 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -247,6 +247,103 @@ namespace Emby.Server.Implementations.Library
return result;
}
+ /// <inheritdoc />
+ public VersionResumeData? GetResumeUserData(User user, BaseItem item)
+ {
+ return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id);
+ }
+
+ /// <inheritdoc />
+ public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ var result = new Dictionary<Guid, VersionResumeData>();
+
+ // Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data.
+ // Linked alternates are already known in memory; only the local-alternate existence check
+ // would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions),
+ // so collect those ids and resolve them all in a single query below.
+ List<Video>? candidates = null;
+ List<Guid>? localProbeIds = null;
+ foreach (var item in items)
+ {
+ if (item is not Video video || video.PrimaryVersionId.HasValue)
+ {
+ continue;
+ }
+
+ (candidates ??= []).Add(video);
+
+ if (video.LinkedAlternateVersions.Length == 0)
+ {
+ (localProbeIds ??= []).Add(video.Id);
+ }
+ }
+
+ if (candidates is null)
+ {
+ return result;
+ }
+
+ HashSet<Guid>? withLocalAlternates = null;
+ if (localProbeIds is not null)
+ {
+ using var dbContext = _repository.CreateDbContext();
+ withLocalAlternates = dbContext.LinkedChildren
+ .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion
+ && localProbeIds.Contains(lc.ParentId))
+ .Select(lc => lc.ParentId)
+ .Distinct()
+ .ToHashSet();
+ }
+
+ List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null;
+ List<BaseItem>? allVersions = null;
+
+ foreach (var video in candidates)
+ {
+ // Only items that actually have alternate versions aggregate over them.
+ if (video.LinkedAlternateVersions.Length == 0
+ && (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id)))
+ {
+ continue;
+ }
+
+ var versions = video.GetAllVersions();
+ if (versions.Count < 2)
+ {
+ continue;
+ }
+
+ (versionGroups ??= []).Add((video.Id, versions));
+ (allVersions ??= []).AddRange(versions);
+ }
+
+ if (versionGroups is null)
+ {
+ return result;
+ }
+
+ var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user);
+
+ foreach (var (primaryId, versions) in versionGroups)
+ {
+ // Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
+ var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ versions,
+ version => userDataByVersion.GetValueOrDefault(version.Id),
+ data => data.PlaybackPositionTicks > 0 || data.Played);
+
+ if (resumeVersion is not null)
+ {
+ result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]);
+ }
+ }
+
+ return result;
+ }
+
/// <summary>
/// Gets the internal key.
/// </summary>
@@ -281,6 +378,10 @@ namespace Emby.Server.Implementations.Library
var dto = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
+
+ // For an item with alternate versions, surface the most recently played version's resume point.
+ GetResumeUserData(user, item)?.ApplyTo(dto);
+
return dto;
}
@@ -385,5 +486,41 @@ namespace Emby.Server.Implementations.Library
return playedToCompletion;
}
+
+ /// <inheritdoc />
+ public void ResetPlaybackStreamSelections(User user, BaseItem item)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(item);
+
+ using var dbContext = _repository.CreateDbContext();
+ var rows = dbContext.UserData
+ .Where(e => e.ItemId == item.Id && e.UserId == user.Id
+ && (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null))
+ .ToList();
+
+ if (rows.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var row in rows)
+ {
+ row.AudioStreamIndex = null;
+ row.SubtitleStreamIndex = null;
+ }
+
+ dbContext.SaveChanges();
+
+ var cacheKey = GetCacheKey(user.InternalId, item.Id);
+ if (_cache.TryGet(cacheKey, out var cached))
+ {
+ cached.AudioStreamIndex = null;
+ cached.SubtitleStreamIndex = null;
+ _cache.AddOrUpdate(cacheKey, cached);
+ }
+
+ item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
+ }
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/az.json b/Emby.Server.Implementations/Localization/Core/az.json
new file mode 100644
index 0000000000..6ab18c8534
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/az.json
@@ -0,0 +1,19 @@
+{
+ "Books": "Kitablar",
+ "HomeVideos": "Ev Videoları",
+ "Latest": "Ən son",
+ "MixedContent": "Qarışıq məzmun",
+ "Movies": "Filmlər",
+ "Music": "Musiqi",
+ "MusicVideos": "Musiqi Videoları",
+ "NameSeasonUnknown": "Mövsüm Naməlum",
+ "NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.",
+ "NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur",
+ "NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb",
+ "NotificationOptionAudioPlayback": "Audio oxutma başladı",
+ "NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı",
+ "NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi",
+ "NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu",
+ "NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi",
+ "NotificationOptionPluginError": "Plugin uğursuzluğu"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 697d9c090f..de56b6fd66 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -9,7 +9,7 @@
"Favorites": "Favoritter",
"Folders": "Mapper",
"Genres": "Genrer",
- "HeaderContinueWatching": "Fortsæt afspilning",
+ "HeaderContinueWatching": "Fortsæt med at se",
"HeaderFavoriteEpisodes": "Yndlingsafsnit",
"HeaderFavoriteShows": "Yndlingsserier",
"HeaderLiveTV": "Live-TV",
@@ -65,7 +65,7 @@
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
"TaskDownloadMissingSubtitles": "Hent manglende undertekster",
"TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
- "TaskUpdatePlugins": "Opdater plugins",
+ "TaskUpdatePlugins": "Opdatér plugins",
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
"TaskCleanLogs": "Ryd log-mappe",
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
@@ -79,10 +79,10 @@
"TaskRefreshChapterImages": "Udtræk kapitelbilleder",
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
- "TaskRefreshChannels": "Opdater kanaler",
+ "TaskRefreshChannels": "Opdatér kanaler",
"TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
"TaskCleanTranscode": "Tøm omkodningsmappen",
- "TaskRefreshPeople": "Opdater personer",
+ "TaskRefreshPeople": "Opdatér personer",
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
"TaskCleanActivityLog": "Ryd aktivitetslog",
@@ -90,7 +90,7 @@
"Forced": "Tvunget",
"Default": "Standard",
"TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.",
- "TaskOptimizeDatabase": "Optimer database",
+ "TaskOptimizeDatabase": "Optimér database",
"TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.",
"TaskKeyframeExtractor": "Udtræk nøglerammer",
"External": "Ekstern",
@@ -99,7 +99,7 @@
"TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.",
"TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.",
"TaskAudioNormalization": "Lydnormalisering",
- "TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen",
+ "TaskDownloadMissingLyricsDescription": "Download sangtekster",
"TaskDownloadMissingLyrics": "Hent manglende sangtekster",
"TaskExtractMediaSegments": "Scan for mediesegmenter",
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index d84afdc1b6..c0ad2c165a 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -50,7 +50,7 @@
"ScheduledTaskFailedWithName": "{0} αποτυχία",
"Shows": "Σειρές",
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
- "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
+ "SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}",
"TvShows": "Τηλεοπτικές Σειρές",
"UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε",
"UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί",
@@ -106,5 +106,7 @@
"TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων",
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
- "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη"
+ "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
+ "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
+ "Original": "Πρωτότυπο"
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index be152b515f..298d60d277 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
"CleanupUserDataTask": "User data cleanup task",
- "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days."
+ "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.",
+ "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
+ "Original": "Original"
}
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-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json
index 28366a41b7..bccfdd4c19 100644
--- a/Emby.Server.Implementations/Localization/Core/es-AR.json
+++ b/Emby.Server.Implementations/Localization/Core/es-AR.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
"TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay",
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
- "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios"
+ "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios",
+ "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
+ "Original": "Original"
}
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/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json
index 1f13451060..4404354a88 100644
--- a/Emby.Server.Implementations/Localization/Core/es_419.json
+++ b/Emby.Server.Implementations/Localization/Core/es_419.json
@@ -106,5 +106,7 @@
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
"CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
- "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días."
+ "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
+ "LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}",
+ "Original": "Original"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 4fb9f4329c..e4e5dcf0e5 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -1,7 +1,7 @@
{
"Artists": "Listafólk",
"Collections": "Søvn",
- "Default": "Sjálvgildi",
+ "Default": "Forsett",
"External": "Ytri",
"Genres": "Greinar",
"AppDeviceValues": "App: {0}, Eind: {1}",
@@ -12,5 +12,38 @@
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
- "LabelIpAddressValue": "IP atsetur: {0}"
+ "LabelIpAddressValue": "IP-atsetur: {0}",
+ "AuthenticationSucceededWithUserName": "{0} varð samgildur",
+ "HeaderFavoriteShows": "Yndisrøðir",
+ "HeaderLiveTV": "Beinleiðis sjónvarp",
+ "HearingImpaired": "Hoyrnarveik",
+ "Inherit": "Arvar",
+ "LabelRunningTimeValue": "Spælitíð: {0}",
+ "Latest": "Seinastu",
+ "LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}",
+ "NameInstallFailed": "{0} innlegging miseydnaðist",
+ "NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
+ "NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
+ "NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
+ "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
+ "NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
+ "NotificationOptionUserLockedOut": "Brúkari útihýstur",
+ "Photos": "Ljósmyndir",
+ "PluginInstalledWithName": "{0} var innlagt",
+ "PluginUninstalledWithName": "{0} var strikað",
+ "PluginUpdatedWithName": "{0} varð dagført",
+ "Shows": "Røðir",
+ "SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
+ "TvShows": "Sjónvarpsrøðir",
+ "UserCreatedWithName": "Brúkari {0} er stovnaður",
+ "UserDeletedWithName": "Brúkari {0} er strikaður",
+ "UserDownloadingItemWithValues": "{0} niðurtekur {1}",
+ "UserLockedOutWithName": "Brúkari {0} er útihýstur",
+ "VersionNumber": "Útgáva {0}",
+ "TasksLibraryCategory": "Savn",
+ "TaskRefreshLibrary": "Skanna miðlasavn",
+ "TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.",
+ "TaskUpdatePlugins": "Dagfør ískoytisforrit",
+ "TaskRefreshChannels": "Endurinnles rásir",
+ "TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir"
}
diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json
index d3740130ee..a68db0076a 100644
--- a/Emby.Server.Implementations/Localization/Core/gl.json
+++ b/Emby.Server.Implementations/Localization/Core/gl.json
@@ -106,5 +106,6 @@
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
- "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
+ "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
+ "Original": "Orixinal"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index af34bf092e..48056b0bb8 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -106,5 +106,7 @@
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
- "CleanupUserDataTask": "משימת ניקוי מידע משתמש"
+ "CleanupUserDataTask": "משימת ניקוי מידע משתמש",
+ "LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
+ "Original": "מקור"
}
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/id.json b/Emby.Server.Implementations/Localization/Core/id.json
index 65c03e70f2..3502ec39ad 100644
--- a/Emby.Server.Implementations/Localization/Core/id.json
+++ b/Emby.Server.Implementations/Localization/Core/id.json
@@ -106,5 +106,7 @@
"TaskExtractMediaSegments": "Scan Segmen media",
"TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
"TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang",
- "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna"
+ "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna",
+ "LyricDownloadFailureFromForItem": "Lirik gagal di download dari {0} untuk {1}",
+ "Original": "Asli"
}
diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json
index c9ca00afdf..44e057e4de 100644
--- a/Emby.Server.Implementations/Localization/Core/is.json
+++ b/Emby.Server.Implementations/Localization/Core/is.json
@@ -103,5 +103,8 @@
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
"TaskExtractMediaSegments": "Skönnun efnishluta",
"CleanupUserDataTask": "Hreinsun notendagagna",
- "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga."
+ "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.",
+ "LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}",
+ "Original": "Upprunaleg",
+ "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index 39e5af717c..78b7ec744b 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -106,5 +106,7 @@
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
- "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。"
+ "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。",
+ "LyricDownloadFailureFromForItem": "歌詞",
+ "Original": "オリジナル"
}
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/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json
index f053619a7a..6009b50fe0 100644
--- a/Emby.Server.Implementations/Localization/Core/kn.json
+++ b/Emby.Server.Implementations/Localization/Core/kn.json
@@ -80,7 +80,7 @@
"NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ",
"NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ",
"NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
- "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
+ "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
"NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
"NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ",
"NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ",
diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json
index 5d64405d19..a210125d34 100644
--- a/Emby.Server.Implementations/Localization/Core/ko.json
+++ b/Emby.Server.Implementations/Localization/Core/ko.json
@@ -106,5 +106,7 @@
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
"CleanupUserDataTask": "사용자 데이터 정리 작업",
- "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
+ "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/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index dd482d1e9b..ce7f6d120e 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -107,5 +107,6 @@
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
"CleanupUserDataTask": "Limpeza de dados de utilizador",
- "Original": "Original"
+ "Original": "Original",
+ "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json
index 0967ef424b..8efc0d1f2e 100644
--- a/Emby.Server.Implementations/Localization/Core/si.json
+++ b/Emby.Server.Implementations/Localization/Core/si.json
@@ -1 +1,15 @@
-{}
+{
+ "AppDeviceValues": "ඇප්: {0}, උපාංග: {1}",
+ "Artists": "කලාකරුවන්",
+ "AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී",
+ "Books": "පොත්",
+ "ChapterNameValue": "{0} වෙනි පරිච්ඡේදය",
+ "Collections": "සංහිතා",
+ "Default": "පෙරනිමි",
+ "External": "බාහිර",
+ "FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය",
+ "Favorites": "ප්‍රියතමයන්",
+ "Folders": "ෆෝල්ඩර",
+ "Forced": "නියමිත",
+ "Genres": "ප්‍රභේද"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index afea835bd4..7ae8857e5d 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -106,5 +106,7 @@
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
"CleanupUserDataTask": "Prečistiť používateľské dáta",
- "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní."
+ "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.",
+ "LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
+ "Original": "Originál"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
index 8c8ed3254a..a1b5b714af 100644
--- a/Emby.Server.Implementations/Localization/Core/sl-SI.json
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -106,5 +106,7 @@
"TaskAudioNormalization": "Normalizacija zvoka",
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
- "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo."
+ "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.",
+ "LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
+ "Original": "Original"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json
index b1f76aafbb..e13f7b09e1 100644
--- a/Emby.Server.Implementations/Localization/Core/sq.json
+++ b/Emby.Server.Implementations/Localization/Core/sq.json
@@ -106,5 +106,7 @@
"TaskAudioNormalization": "Normalizimi i audios",
"TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.",
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
- "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve"
+ "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
+ "LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
+ "Original": "Origjinal"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json
index 56806e25c1..92f309c80c 100644
--- a/Emby.Server.Implementations/Localization/Core/sr.json
+++ b/Emby.Server.Implementations/Localization/Core/sr.json
@@ -106,5 +106,7 @@
"CleanupUserDataTask": "Задатак чишћења корисничких података",
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
- "TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
+ "TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
+ "LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
+ "Original": "Изворно"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sw.json b/Emby.Server.Implementations/Localization/Core/sw.json
index 0967ef424b..c117a0eae2 100644
--- a/Emby.Server.Implementations/Localization/Core/sw.json
+++ b/Emby.Server.Implementations/Localization/Core/sw.json
@@ -1 +1,5 @@
-{}
+{
+ "Artists": "Wasanii",
+ "Books": "Vitabu",
+ "Collections": "Mikusanyiko"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index 8b9665cf9a..b2fcbc93a2 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -77,8 +77,8 @@
"TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。",
"TaskCleanLogs": "清理日誌資料夾",
"TaskRefreshLibrary": "掃描媒體櫃",
- "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。",
- "TaskRefreshChapterImages": "擷取章節圖片",
+ "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。",
+ "TaskRefreshChapterImages": "擷取章節圖像",
"TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。",
"TaskCleanCache": "清理快取(Cache)資料夾",
"TasksChannelsCategory": "網路頻道",
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。",
"TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置",
"CleanupUserDataTask": "清理使用者資料嘅任務",
- "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。"
+ "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
+ "LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
+ "Original": "原始"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index 5dace3b0b7..4e0dff87b9 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
"CleanupUserDataTask": "用戶資料清理工作",
- "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
+ "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
+ "Original": "原作",
+ "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
}
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 843e35afcc..e7dd984ec4 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -356,6 +356,27 @@ namespace Emby.Server.Implementations.Localization
{
ArgumentException.ThrowIfNullOrEmpty(rating);
+ // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år").
+ // Try each one in order and use the first that resolves.
+ var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var ratingValue in ratingValues)
+ {
+ var score = GetSingleRatingScore(ratingValue, countryCode);
+ if (score is not null)
+ {
+ return score;
+ }
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Resolves a single rating value to a score.
+ /// </summary>
+ private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode)
+ {
// Handle unrated content
if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
{
@@ -364,7 +385,7 @@ namespace Emby.Server.Implementations.Localization
// Convert ints directly
// This may override some of the locale specific age ratings (but those always map to the same age)
- if (int.TryParse(rating, out var ratingAge))
+ if (TryParseRatingAsScore(rating, out var ratingAge))
{
return new(ratingAge, null);
}
@@ -466,6 +487,13 @@ namespace Emby.Server.Implementations.Localization
return true;
}
+ // If it's not a recognized rating string, fall back to using the number as the score
+ if (TryParseRatingAsScore(ratingPart, out var numericScore))
+ {
+ result = new ParentalRatingScore(numericScore, null);
+ return true;
+ }
+
_logger.LogWarning(
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
rating,
@@ -480,6 +508,18 @@ namespace Emby.Server.Implementations.Localization
return true;
}
+ /// <summary>
+ /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
+ /// </summary>
+ /// <param name="ratingValue">Rating value to parse.</param>
+ /// <param name="score">Parsed score.</param>
+ /// <returns>Returns true if parsing was successful.</returns>
+ private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
+ {
+ var trimmed = ratingValue.TrimEnd('+');
+ return int.TryParse(trimmed, out score);
+ }
+
/// <inheritdoc />
public string GetLocalizedString(string phrase)
{
@@ -566,11 +606,15 @@ namespace Emby.Server.Implementations.Localization
private static string GetResourceFilename(string culture)
{
- var parts = culture.Split('-');
+ // Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator.
+ // Normalize the casing (lower-case language, upper-case region) while preserving the separator
+ // so the result matches the embedded resource file name, which is case-sensitive.
+ var separatorIndex = culture.IndexOfAny(['-', '_']);
- if (parts.Length == 2)
+ if (separatorIndex > 0)
{
- culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
+ var separator = culture[separatorIndex];
+ culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant();
}
else
{
diff --git a/Emby.Server.Implementations/Localization/Ratings/gr.json b/Emby.Server.Implementations/Localization/Ratings/gr.json
index 794bf0b313..73abab72d3 100644
--- a/Emby.Server.Implementations/Localization/Ratings/gr.json
+++ b/Emby.Server.Implementations/Localization/Ratings/gr.json
@@ -10,21 +10,42 @@
}
},
{
- "ratingStrings": ["K12"],
+ "ratingStrings": ["K12", "K-12", "12"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K13", "K-13", "13"],
"ratingScore": {
"score": 13,
"subScore": null
}
},
{
- "ratingStrings": ["K15"],
+ "ratingStrings": ["K15", "K-15", "15"],
"ratingScore": {
"score": 15,
"subScore": null
}
},
{
- "ratingStrings": ["K18"],
+ "ratingStrings": ["K16", "K-16", "16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K17", "K-17", "17"],
+ "ratingScore": {
+ "score": 17,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K18", "K-18", "18", "18+"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/se.json b/Emby.Server.Implementations/Localization/Ratings/se.json
index 70084995d1..818565e16b 100644
--- a/Emby.Server.Implementations/Localization/Ratings/se.json
+++ b/Emby.Server.Implementations/Localization/Ratings/se.json
@@ -10,7 +10,7 @@
}
},
{
- "ratingStrings": ["7"],
+ "ratingStrings": ["7", "7+", "7 År", "Från 7 år"],
"ratingScore": {
"score": 7,
"subScore": null
@@ -31,7 +31,7 @@
}
},
{
- "ratingStrings": ["11"],
+ "ratingStrings": ["11", "11+", "11 År", "Från 11 år"],
"ratingScore": {
"score": 11,
"subScore": null
@@ -45,11 +45,18 @@
}
},
{
- "ratingStrings": ["15"],
+ "ratingStrings": ["15", "15+", "15 År", "Från 15 år"],
"ratingScore": {
"score": 15,
"subScore": null
}
+ },
+ {
+ "ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
}
]
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
index b2dc89be28..e4939205c9 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask
{
FileName = _mediaEncoder.EncoderPath,
Arguments = args,
- RedirectStandardOutput = false,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
},
})
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
index f81309560e..f1e1579a1d 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
@@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask
EnableImages = false
},
SourceTypes = [SourceType.Library],
- IsVirtualItem = false
+ IsVirtualItem = false,
+ IncludeOwnedItems = true
})
.OfType<Video>()
.ToList();
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
index 5e92808f78..9cc6eb265a 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
@@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
+ IncludeOwnedItems = true,
Limit = pagesize
};
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
index 92d7a3907a..8d133dc074 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
private readonly ILogger<OptimizeDatabaseTask> _logger;
private readonly ILocalizationManager _localization;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
+ private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
@@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public OptimizeDatabaseTask(
ILogger<OptimizeDatabaseTask> logger,
ILocalizationManager localization,
- IJellyfinDatabaseProvider jellyfinDatabaseProvider)
+ IJellyfinDatabaseProvider jellyfinDatabaseProvider,
+ ILibraryManager libraryManager)
{
_logger = logger;
_localization = localization;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+ _libraryManager = libraryManager;
}
/// <inheritdoc />
@@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
+ // Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in
+ // progress causes both operations to contend for the database and can stall the scan, so defer optimization
+ // until no scan is running. The task will run again on its next trigger.
+ if (_libraryManager.IsScanRunning)
+ {
+ _logger.LogInformation("Skipping database optimization because a library scan is currently running.");
+ return;
+ }
+
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
try
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
index 6e4e5c7808..dff9a473af 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
@@ -4,11 +4,18 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
@@ -20,6 +27,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
private readonly ILibraryManager _libraryManager;
private readonly ILocalizationManager _localization;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<PeopleValidationTask> _logger;
+ private readonly IItemTypeLookup _itemTypeLookup;
/// <summary>
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
@@ -27,11 +37,23 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
- public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
+ /// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
+ public PeopleValidationTask(
+ ILibraryManager libraryManager,
+ ILocalizationManager localization,
+ IDbContextFactory<JellyfinDbContext> dbContextFactory,
+ IFileSystem fileSystem,
+ ILogger<PeopleValidationTask> logger,
+ IItemTypeLookup itemTypeLookup)
{
_libraryManager = libraryManager;
_localization = localization;
_dbContextFactory = dbContextFactory;
+ _fileSystem = fileSystem;
+ _logger = logger;
+ _itemTypeLookup = itemTypeLookup;
}
/// <inheritdoc />
@@ -71,13 +93,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
- IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
- await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
+ // People validation performs heavy database writes that contend with an active library scan.
+ // Defer it until the scan has finished; the task will run again on its next trigger.
+ if (_libraryManager.IsScanRunning)
+ {
+ _logger.LogInformation("Skipping people validation because a library scan is currently running.");
+ return;
+ }
- subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
+ // Phase 1: Deduplicate and remove orphaned people (0-33%)
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
+ IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3));
var dupQuery = context.Peoples
.GroupBy(e => new { e.Name, e.PersonType })
.Where(e => e.Count() > 1)
@@ -123,7 +151,113 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
ArrayPool<Guid[]>.Shared.Return(buffer);
}
+ var peopleToDelete = await context.Peoples
+ .Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id)))
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ _logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete);
+
subProgress.Report(100);
}
+
+ // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
+ // cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
+ IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
+ await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
+
+ // Phase 3: Refresh images for people missing them (66-100%)
+ IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
+ await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false);
+
+ progress.Report(100);
+ }
+
+ private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
+
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ const int PartitionSize = 100;
+
+ var numPeople = await context.BaseItems
+ .AsNoTracking()
+ .Where(b => b.Type == personTypeName)
+ .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
+ .Where(b =>
+ !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
+ string.IsNullOrEmpty(b.Overview))
+ .CountAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
+
+ if (numPeople == 0)
+ {
+ progress.Report(100);
+ return;
+ }
+
+ var numComplete = 0;
+ var numRefreshed = 0;
+
+ await foreach (var entry in context.BaseItems
+ .AsNoTracking()
+ .Where(b => b.Type == personTypeName)
+ .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
+ .Where(b =>
+ !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
+ string.IsNullOrEmpty(b.Overview))
+ .OrderBy(b => b.Id)
+ .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition))
+ .PartitionEagerAsync(PartitionSize, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
+ {
+ numRefreshed++;
+ }
+
+ numComplete++;
+ progress.Report(100.0 * numComplete / numPeople);
+ }
+
+ _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
+ }
+ }
+
+ private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (_libraryManager.GetItemById(personId) is not Person item)
+ {
+ return false;
+ }
+
+ var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary);
+ var hasOverview = !string.IsNullOrEmpty(item.Overview);
+
+ var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ {
+ ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
+ MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
+ };
+
+ await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId);
+ return false;
+ }
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 5148b62655..828bdd6859 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
}
}
+ else
+ {
+ liveStreamNeedsToBeClosed = true;
+ }
if (liveStreamNeedsToBeClosed)
{
@@ -453,18 +457,6 @@ namespace Emby.Server.Implementations.Session
session.PlayState.RepeatMode = info.RepeatMode;
session.PlayState.PlaybackOrder = info.PlaybackOrder;
session.PlaylistItemId = info.PlaylistItemId;
-
- var nowPlayingQueue = info.NowPlayingQueue;
-
- if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue))
- {
- session.NowPlayingQueue = nowPlayingQueue;
-
- var itemIds = Array.ConvertAll(nowPlayingQueue, queue => queue.Id);
- session.NowPlayingQueueFullItems = _dtoService.GetBaseItemDtos(
- _libraryManager.GetItemList(new InternalItemsQuery { ItemIds = itemIds }),
- new DtoOptions(true));
- }
}
/// <summary>
@@ -738,6 +730,31 @@ namespace Emby.Server.Implementations.Session
}
/// <summary>
+ /// Resolves the item whose user data (playback position, played status) should be updated
+ /// for a playback report. When an alternate version is played the client reports the displayed
+ /// item as <c>ItemId</c> and the played version as <c>MediaSourceId</c>.
+ /// </summary>
+ /// <param name="libraryItem">The now playing (displayed) item.</param>
+ /// <param name="mediaSourceId">The reported media source id.</param>
+ /// <returns>The item to track progress against.</returns>
+ private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId)
+ {
+ if (libraryItem is Video libraryVideo
+ && !string.IsNullOrEmpty(mediaSourceId)
+ && Guid.TryParse(mediaSourceId, out var mediaSourceItemId)
+ && !mediaSourceItemId.Equals(libraryVideo.Id))
+ {
+ var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId);
+ if (versionItem is not null)
+ {
+ return versionItem;
+ }
+ }
+
+ return libraryItem;
+ }
+
+ /// <summary>
/// Used to report that playback has started for an item.
/// </summary>
/// <param name="info">The info.</param>
@@ -768,9 +785,10 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- OnPlaybackStart(user, libraryItem);
+ OnPlaybackStart(user, progressItem);
}
}
@@ -902,9 +920,10 @@ namespace Emby.Server.Implementations.Session
// only update saved user data on actual check-ins, not automated ones
if (libraryItem is not null && !isAutomated)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- OnPlaybackProgress(user, libraryItem, info);
+ OnPlaybackProgress(user, progressItem, info);
}
}
@@ -964,6 +983,20 @@ namespace Emby.Server.Implementations.Session
if (changed)
{
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None);
+
+ // A completed version marks every alternate version played and clears their resume points, so the
+ // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
+ // only persist while nothing has been completed yet.)
+ if (data.Played == true && item is Video playedVideo)
+ {
+ playedVideo.PropagatePlayedState(user, true);
+ }
+ }
+
+ if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue)
+ || (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue))
+ {
+ _userDataManager.ResetPlaybackStreamSelections(user, item);
}
}
@@ -1095,9 +1128,10 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed);
+ playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed);
}
}
@@ -1150,6 +1184,14 @@ namespace Emby.Server.Implementations.Session
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None);
+ // A completed version marks every alternate version played and clears their resume points, so the
+ // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
+ // only persist while nothing has been completed yet.)
+ if (data.Played == true && item is Video playedVideo)
+ {
+ playedVideo.PropagatePlayedState(user, true);
+ }
+
return playedToCompletion;
}
@@ -1217,7 +1259,6 @@ namespace Emby.Server.Implementations.Session
SupportsMediaControl = sessionInfo.SupportsMediaControl,
SupportsRemoteControl = sessionInfo.SupportsRemoteControl,
NowPlayingQueue = sessionInfo.NowPlayingQueue,
- NowPlayingQueueFullItems = sessionInfo.NowPlayingQueueFullItems,
HasCustomDeviceName = sessionInfo.HasCustomDeviceName,
PlaylistItemId = sessionInfo.PlaylistItemId,
ServerId = sessionInfo.ServerId,
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index 6a26e92e14..2582ed9df0 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session
_logger.LogInformation("Lost {0} WebSockets.", lost.Count);
foreach (var webSocket in lost)
{
- // TODO: handle session relative to the lost webSocket
RemoveWebSocket(webSocket);
+
+ // The connection stopped answering keep-alives, so a close frame will
+ // never arrive and the pending receive loop would hang forever, keeping
+ // the session (and e.g. its SyncPlay group membership) alive. Disposing
+ // the connection aborts the receive loop, which raises Closed and lets
+ // the session end normally.
+ try
+ {
+ webSocket.Dispose();
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint);
+ }
}
}
}
diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs
index c2e834ad58..38a0018a70 100644
--- a/Emby.Server.Implementations/SyncPlay/Group.cs
+++ b/Emby.Server.Implementations/SyncPlay/Group.cs
@@ -206,7 +206,8 @@ namespace Emby.Server.Implementations.SyncPlay
foreach (var itemId in queue)
{
var item = _libraryManager.GetItemById(itemId);
- if (!item.IsVisibleStandalone(user))
+
+ if (item is null || !item.IsVisibleStandalone(user))
{
return false;
}
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
index 535dc01a31..459ad1a17e 100644
--- a/Emby.Server.Implementations/TV/TVSeriesManager.cs
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
@@ -136,11 +137,15 @@ namespace Emby.Server.Implementations.TV
if (nextEpisode is not null)
{
+ // The last played date and the version that was actually played live on the version item's user data
+ // The played state propagated to the sibling versions carries no date
+ var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
+ nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
+
DateTime lastWatchedDate = DateTime.MinValue;
if (result.LastWatched is not null)
{
- var userData = _userDataManager.GetUserData(user, result.LastWatched);
- lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
+ lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
}
nextUpList.Add((lastWatchedDate, nextEpisode));
@@ -152,11 +157,13 @@ namespace Emby.Server.Implementations.TV
if (nextPlayedEpisode is not null)
{
+ var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user);
+ nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion);
+
DateTime rewatchLastWatchedDate = DateTime.MinValue;
if (result.LastWatchedForRewatching is not null)
{
- var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching);
- rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
+ rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
}
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
@@ -219,10 +226,13 @@ namespace Emby.Server.Implementations.TV
if (nextEpisode is not null && !includeResumable)
{
- var userData = _userDataManager.GetUserData(user, nextEpisode);
- if (userData?.PlaybackPositionTicks > 0)
+ // The resume progress may live on an alternate version
+ foreach (var version in nextEpisode.GetAllVersions())
{
- return null;
+ if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0)
+ {
+ return null;
+ }
}
}
@@ -237,6 +247,74 @@ namespace Emby.Server.Implementations.TV
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
}
+ /// <summary>
+ /// Gets the version of the last watched episode that was actually played, together with its last played date.
+ /// The version that was played carries the most recent LastPlayedDate.
+ /// dates.
+ /// </summary>
+ /// <param name="lastWatched">The last watched episode (any version).</param>
+ /// <param name="user">The user.</param>
+ /// <returns>The played version and its last played date.</returns>
+ private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user)
+ {
+ if (lastWatched is not Video lastWatchedVideo)
+ {
+ return (null, null);
+ }
+
+ var versions = lastWatchedVideo.GetAllVersions();
+ var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
+
+ var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ versions,
+ version => userDataByVersion.GetValueOrDefault(version.Id),
+ data => data.LastPlayedDate.HasValue);
+
+ return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
+ }
+
+ /// <summary>
+ /// When the last watched episode was played as an alternate version, prefer the next episode's version with the matching name,
+ /// so Next Up continues in the version the user has been watching instead of falling back to the primary.
+ /// </summary>
+ /// <param name="nextEpisode">The determined next episode (a primary).</param>
+ /// <param name="lastWatched">The last watched episode.</param>
+ /// <param name="playedVersion">The version of the last watched episode that was played.</param>
+ /// <returns>The matching version of the next episode, or the episode itself.</returns>
+ private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion)
+ {
+ // No version preference, or the primary was played
+ if (lastWatched is not Video lastWatchedVideo
+ || playedVersion is null
+ || !playedVersion.PrimaryVersionId.HasValue)
+ {
+ return nextEpisode;
+ }
+
+ // Match by version name
+ var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture);
+ var playedVersionName = lastWatchedVideo.GetMediaSources(false)
+ .FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))?.Name;
+
+ if (string.IsNullOrEmpty(playedVersionName))
+ {
+ return nextEpisode;
+ }
+
+ var matchingSource = nextEpisode.GetMediaSources(false)
+ .FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCase));
+
+ if (matchingSource is not null
+ && Guid.TryParse(matchingSource.Id, out var matchingId)
+ && !matchingId.Equals(nextEpisode.Id)
+ && _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion)
+ {
+ return matchingVersion;
+ }
+
+ return nextEpisode;
+ }
+
private static string GetUniqueSeriesKey(Series series)
{
return series.GetPresentationUniqueKey();
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 67b77a112d..6a60f7f5f6 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -1,4 +1,5 @@
using System;
+using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
@@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates
/// </summary>
public class InstallationManager : IInstallationManager
{
+ private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
+
/// <summary>
/// The logger.
/// </summary>
@@ -521,48 +524,68 @@ namespace Emby.Server.Implementations.Updates
return;
}
+ if (!IsValidPackageDirectoryName(package.Name))
+ {
+ _logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name);
+ throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name.");
+ }
+
// Always override the passed-in target (which is a file) and figure it out again
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
- 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
-#pragma warning disable CA5351
- cancellationToken.ThrowIfCancellationRequested();
-
- var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
- if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
+ var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath));
+ var resolvedTarget = Path.GetFullPath(targetDir);
+ if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
- "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
+ "Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.",
package.Name,
- package.Checksum,
- hash);
- throw new InvalidDataException("The checksum of the received data doesn't match.");
+ resolvedTarget,
+ pluginsRoot);
+ throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
}
- // Version folder as they cannot be overwritten in Windows.
- targetDir += "_" + package.Version;
-
- if (Directory.Exists(targetDir))
+ using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
{
- try
+ // CA5351: Do Not Use Broken Cryptographic Algorithms
+#pragma warning disable CA5351
+ cancellationToken.ThrowIfCancellationRequested();
+
+ 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);
@@ -570,6 +593,26 @@ namespace Emby.Server.Implementations.Updates
_pluginManager.ImportPluginFrom(targetDir);
}
+ private static bool IsValidPackageDirectoryName(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return false;
+ }
+
+ if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (name.IndexOfAny(InvalidPackageNameChars) >= 0)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
{
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))