diff options
Diffstat (limited to 'Emby.Server.Implementations')
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)) |
