diff options
Diffstat (limited to 'Emby.Server.Implementations')
119 files changed, 4065 insertions, 986 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 7cbff0c67e..1a54565863 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; @@ -38,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Drawing; using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.Networking.Manager; @@ -92,6 +95,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; @@ -413,6 +419,8 @@ namespace Emby.Server.Implementations { Logger.LogInformation("Running startup tasks"); + EnsureStartupWizardIntegrity(); + Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; @@ -432,6 +440,24 @@ namespace Emby.Server.Implementations return Task.CompletedTask; } + private void EnsureStartupWizardIntegrity() + { + if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted) + { + return; + } + + var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers() + .Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password)); + + if (hasConfiguredAdministrator) + { + Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable."); + ConfigurationManager.Configuration.IsStartupWizardCompleted = true; + ConfigurationManager.SaveConfiguration(); + } + } + /// <inheritdoc/> public void Init(IServiceCollection serviceCollection) { @@ -495,6 +521,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>(); @@ -551,7 +585,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>(); @@ -710,6 +745,7 @@ namespace Emby.Server.Implementations Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>()); + Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>()); } /// <summary> @@ -951,8 +987,9 @@ namespace Emby.Server.Implementations /// <inheritdoc/> public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { - // If the smartAPI doesn't start with http then treat it as a host or ip. - if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + // If the smartAPI isn't already a complete URL then treat it as a host or ip. + if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return hostname.TrimEnd('/'); } diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 295efd456c..84d50f5121 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -107,7 +107,8 @@ namespace Emby.Server.Implementations.Collections SaveLocalMetadata = true }; - var name = _localizationManager.GetLocalizedString("Collections"); + // This names a library for the whole server, so ignore the requesting client's language. + var name = _localizationManager.GetServerLocalizedString("Collections"); await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false); 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 f53328c7dd..59e75691dc 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 @@ -179,6 +185,13 @@ namespace Emby.Server.Implementations.Dto allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); } + // Batch-fetch by-name item counts to avoid N+1 queries + Dictionary<Guid, ItemCounts>? itemCountsBatch = null; + if (options.ContainsField(ItemFields.ItemCounts)) + { + itemCountsBatch = GetItemCountsBatch(accessibleItems, user); + } + // Batch-fetch child counts for all folders to avoid N+1 queries Dictionary<Guid, int>? childCountBatch = null; if (options.ContainsField(ItemFields.ChildCount)) @@ -186,7 +199,7 @@ namespace Emby.Server.Implementations.Dto var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList(); if (folderIds.Count > 0) { - childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id); + childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user); } } @@ -236,6 +249,29 @@ namespace Emby.Server.Implementations.Dto artistsBatch = _libraryManager.GetArtists(artistNames.ToArray()); } + // Batch-fetch people across all items to avoid one GetPeople query per item. + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null; + if (options.ContainsField(ItemFields.People)) + { + var peopleItemIds = accessibleItems.Where(i => i.SupportsPeople).Select(i => i.Id).ToList(); + if (peopleItemIds.Count > 0) + { + peopleBatch = _libraryManager.GetPeopleByItems(peopleItemIds); + } + } + + // Batch-detect which videos own alternate versions to avoid the per-item alternate-version + // queries in MediaSourceCount. Videos absent from this set have a single media source. + IReadOnlySet<Guid>? alternateVersionItemIds = null; + if (options.ContainsField(ItemFields.MediaSourceCount)) + { + var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList(); + if (versionItemIds.Count > 0) + { + alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds); + } + } + for (int index = 0; index < accessibleItems.Count; index++) { var item = accessibleItems[index]; @@ -248,7 +284,10 @@ namespace Emby.Server.Implementations.Dto allCollectionFolders, childCountBatch, playedCountBatch, - artistsBatch); + artistsBatch, + resumeDataBatch?.GetValueOrDefault(item.Id), + peopleBatch, + alternateVersionItemIds); if (item is LiveTvChannel tvChannel) { @@ -261,7 +300,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ItemCounts)) { - SetItemByNameInfo(dto, user); + SetItemByNameInfo(dto, user, itemCountsBatch); } returnItems[index] = dto; @@ -309,7 +348,10 @@ 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, + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null, + IReadOnlySet<Guid>? alternateVersionItemIds = null) { var dto = new BaseItemDto { @@ -323,7 +365,15 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.People)) { - AttachPeople(dto, item, user); + IReadOnlyList<PersonInfo>? prefetchedPeople = null; + if (peopleBatch is not null) + { + // The batch omits items with no people, so a miss means "no people", + // not "not fetched". Use an empty list to skip the per-item query. + prefetchedPeople = peopleBatch.GetValueOrDefault(item.Id) ?? []; + } + + AttachPeople(dto, item, user, prefetchedPeople); } if (options.ContainsField(ItemFields.PrimaryImageAspectRatio)) @@ -353,7 +403,8 @@ namespace Emby.Server.Implementations.Dto options, userData, childCountBatch, - playedCountBatch); + playedCountBatch, + resumeData); } if (item is IHasMediaSources @@ -369,7 +420,7 @@ namespace Emby.Server.Implementations.Dto AttachStudios(dto, item); } - AttachBasicFields(dto, item, owner, options, artistsBatch); + AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds); if (options.ContainsField(ItemFields.CanDelete)) { @@ -474,14 +525,36 @@ namespace Emby.Server.Implementations.Dto return dto; } - private void SetItemByNameInfo(BaseItemDto dto, User? user) + private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user) + { + var result = new Dictionary<Guid, ItemCounts>(); + + foreach (var group in items.GroupBy(item => item.GetBaseItemKind())) + { + if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds)) + { + continue; + } + + var ids = group.Select(item => item.Id).ToArray(); + foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user)) + { + result[id] = counts; + } + } + + return result; + } + + private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null) { if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds)) { return; } - var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); + var counts = prefetchedCounts?.GetValueOrDefault(dto.Id) + ?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); dto.AlbumCount = counts.AlbumCount; dto.ArtistCount = counts.ArtistCount; @@ -538,7 +611,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) { @@ -566,7 +640,11 @@ namespace Emby.Server.Implementations.Dto // For these types we can try to optimize and assume these values will be equal if (item is MusicAlbum || item is Season || item is Playlist) { - dto.ChildCount = dto.RecursiveItemCount; + if (dto.RecursiveItemCount > 0) + { + dto.ChildCount = dto.RecursiveItemCount; + } + var folderChildCount = folder.LinkedChildren.Length; // The default is an empty array, so we can't reliably use the count when it's empty if (folderChildCount > 0) @@ -600,6 +678,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 { @@ -648,7 +729,8 @@ namespace Emby.Server.Implementations.Dto return count; } - // Fall back to individual query for special cases (Series, Season, etc.) + // Only reached when no batch was computed: the batch holds an entry for every folder it + // was asked about, zero included. return folder.GetChildCount(user); } @@ -729,12 +811,18 @@ namespace Emby.Server.Implementations.Dto /// <param name="dto">The dto.</param> /// <param name="item">The item.</param> /// <param name="user">The requesting user.</param> - private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null) + /// <param name="prefetchedPeople">People fetched in batch by the caller; when null the people are queried per item.</param> + private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null, IReadOnlyList<PersonInfo>? prefetchedPeople = null) { + // When rendering a page of items the caller batch-fetches people for every item up + // front and passes them in, avoiding one GetPeople query per item. Fall back to the + // per-item query for the single item path where no batch is available. + var source = prefetchedPeople ?? _libraryManager.GetPeople(item); + // Ordering by person type to ensure actors and artists are at the front. // This is taking advantage of the fact that they both begin with A // This should be improved in the future - var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue) + var people = source.OrderBy(i => i.SortOrder ?? int.MaxValue) .ThenBy(i => { if (i.IsType(PersonKind.Actor)) @@ -943,7 +1031,9 @@ 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> + /// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param> + private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null) { if (options.ContainsField(ItemFields.DateCreated)) { @@ -1074,7 +1164,7 @@ namespace Emby.Server.Implementations.Dto dto.ParentId = item.DisplayParentId; } - AddInheritedImages(dto, item, options, owner); + AddInheritedImages(dto, item, options, owner, artistsBatch); if (options.ContainsField(ItemFields.Path)) { @@ -1257,10 +1347,27 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaSourceCount)) { - var mediaSourceCount = video.MediaSourceCount; - if (mediaSourceCount != 1) + // A video with no primary version and no alternate versions always has a single + // media source. Only compute the count for videos that might have more: a primary + // version, or membership in the batch's set of items that own alternate versions. + // Without the batch we can't rule it out, so fall back to computing (the single-item + // path). Everything else is the common case and keeps the default count of one. + var mayHaveAlternateVersions = alternateVersionItemIds is null + || video.PrimaryVersionId.HasValue + || alternateVersionItemIds.Contains(video.Id); + + if (mayHaveAlternateVersions) { - dto.MediaSourceCount = 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,38 +1473,22 @@ namespace Emby.Server.Implementations.Dto } } - if (options.PreferEpisodeParentPoster) + if (options.GetImageLimit(ImageType.Primary) > 0) { var episodeSeason = episode.Season; var seasonPrimaryTag = episodeSeason is not null ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary) : null; - BaseItem? posterParent = null; if (seasonPrimaryTag is not null) { dto.ParentPrimaryImageItemId = episodeSeason!.Id; dto.ParentPrimaryImageTag = seasonPrimaryTag; - posterParent = episodeSeason; } else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null) { dto.ParentPrimaryImageItemId = episodeSeries.Id; dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag; - posterParent = episodeSeries; - } - - if (posterParent is not null) - { - if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag)) - { - // Only drop the episode's own primary blurhash; keep the poster parent's. - dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag); - } - - dto.SeriesPrimaryImageTag = null; - dto.PrimaryImageAspectRatio = null; - AttachPrimaryImageAspectRatio(dto, posterParent); } } @@ -1516,11 +1607,11 @@ namespace Emby.Server.Implementations.Dto } } - private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem) + private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) { if (currentItem is MusicAlbum musicAlbum) { - var artist = musicAlbum.GetMusicArtist(new DtoOptions(false)); + var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false)); if (artist is not null) { return artist; @@ -1537,8 +1628,36 @@ namespace Emby.Server.Implementations.Dto return parent; } - private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner) + private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) { + if (artistsBatch is null) + { + return null; + } + + var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null; + return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0 + ? artists[0] + : null; + } + + private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) + { + 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; @@ -1567,7 +1686,7 @@ namespace Emby.Server.Implementations.Dto || (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) || parent is Series) { - parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent; + parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent; if (parent is null) { break; @@ -1626,7 +1745,7 @@ namespace Emby.Server.Implementations.Dto break; } - parent = GetImageDisplayParent(parent, item); + parent = GetImageDisplayParent(parent, item, artistsBatch); } } diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 933cfc8cbe..02b104756e 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints; /// </summary> public sealed class LibraryChangedNotifier : IHostedService, IDisposable { + // A batch holds a live reference to every item it names, so it has to stay small enough that a + // library scan - which changes items faster than any batch window closes - cannot grow it without + // bound. Reached only by a scan; interactive use closes a batch on the window long before this. + internal const int MaxBatchSize = 2000; + private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IProviderManager _providerManager; @@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private readonly ILogger<LibraryChangedNotifier> _logger; private readonly Lock _libraryChangedSyncLock = new(); - private readonly List<Folder> _foldersAddedTo = new(); - private readonly List<Folder> _foldersRemovedFrom = new(); - private readonly List<BaseItem> _itemsAdded = new(); - private readonly List<BaseItem> _itemsRemoved = new(); - private readonly List<BaseItem> _itemsUpdated = new(); + private readonly Dictionary<Guid, Folder> _foldersAddedTo = []; + private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = []; + private readonly Dictionary<Guid, BaseItem> _itemsAdded = []; + private readonly Dictionary<Guid, BaseItem> _itemsRemoved = []; + private readonly Dictionary<Guid, BaseItem> _itemsUpdated = []; private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new(); private Timer? _libraryUpdateTimer; @@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e) => OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom); - private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList) + private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList) { if (!FilterItem(item)) { @@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable lock (_libraryChangedSyncLock) { - var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); - + // The window runs from the first change of a batch and is never extended. Extending it on + // every change would keep a library scan's batch open for the whole scan, and the batch + // holds the items it names alive, so it would grow to the size of the library. if (_libraryUpdateTimer is null) { + var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); _libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan); } - else - { - _libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan); - } if (foldersList is not null && parent is Folder folder) { - foldersList.Add(folder); + foldersList[folder.Id] = folder; } - itemsList.Add(item); + itemsList[item.Id] = item; + + // A window long enough to cover a burst still has to give way once the batch is large + // enough to be worth sending on its own. + if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize) + { + _libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } } } @@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable List<BaseItem> itemsRemoved; lock (_libraryChangedSyncLock) { - // Remove dupes in case some were saved multiple times - foldersAddedTo = _foldersAddedTo - .DistinctBy(x => x.Id) - .ToList(); - - foldersRemovedFrom = _foldersRemovedFrom - .DistinctBy(x => x.Id) - .ToList(); + foldersAddedTo = _foldersAddedTo.Values.ToList(); + foldersRemovedFrom = _foldersRemovedFrom.Values.ToList(); itemsUpdated = _itemsUpdated - .Where(i => !_itemsAdded.Contains(i)) - .DistinctBy(x => x.Id) + .Where(e => !_itemsAdded.ContainsKey(e.Key)) + .Select(e => e.Value) .ToList(); - itemsAdded = _itemsAdded.ToList(); - itemsRemoved = _itemsRemoved.ToList(); + itemsAdded = _itemsAdded.Values.ToList(); + itemsRemoved = _itemsRemoved.Values.ToList(); if (_libraryUpdateTimer is not null) { @@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable _foldersRemovedFrom.Clear(); } + if (itemsAdded.Count == 0 + && itemsUpdated.Count == 0 + && itemsRemoved.Count == 0 + && foldersAddedTo.Count == 0 + && foldersRemovedFrom.Count == 0) + { + return; + } + await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false); } diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index fc174b7c14..b182e5837b 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints public sealed class UserDataChangeNotifier : IHostedService, IDisposable { private const int UpdateDuration = 500; + internal const int MaxBatchSize = 2000; private readonly ISessionManager _sessionManager; private readonly IUserDataManager _userDataManager; private readonly IUserManager _userManager; - private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new(); + private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = []; private readonly Lock _syncLock = new(); private Timer? _updateTimer; + private int _changedItemCount; /// <summary> /// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class. @@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints lock (_syncLock) { - if (_updateTimer is null) + // The window runs from the first change of a batch and is never extended, so a stream + // of changes that never pauses - a library scan - still closes its batches instead of + // holding every item it touched alive until the stream stops. + _updateTimer ??= new Timer( + UpdateTimerCallback, + null, + UpdateDuration, + Timeout.Infinite); + + if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys)) { - _updateTimer = new Timer( - UpdateTimerCallback, - null, - UpdateDuration, - Timeout.Infinite); - } - else - { - _updateTimer.Change(UpdateDuration, Timeout.Infinite); - } - - if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys)) - { - keys = new List<BaseItem>(); + keys = []; _changedItems[e.UserId] = keys; } - keys.Add(e.Item); - var baseItem = e.Item; // Go up one level for indicators if (baseItem is not null) { + Track(keys, baseItem); + var parent = baseItem.GetOwner() ?? baseItem.GetParent(); if (parent is not null) { - keys.Add(parent); + Track(keys, parent); } } + + // A window long enough to cover a burst still has to give way once the batch is + // large enough to be worth sending on its own. + if (_changedItemCount >= MaxBatchSize) + { + _updateTimer.Change(0, Timeout.Infinite); + } + } + } + + private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item) + { + var before = keys.Count; + keys[item.Id] = item; + + if (keys.Count != before) + { + _changedItemCount++; } } private async void UpdateTimerCallback(object? state) { - List<KeyValuePair<Guid, List<BaseItem>>> changes; + List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes; lock (_syncLock) { - // Remove dupes in case some were saved multiple times changes = _changedItems.ToList(); _changedItems.Clear(); + _changedItemCount = 0; if (_updateTimer is not null) { @@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints } } + if (changes.Count == 0) + { + return; + } + foreach (var (userId, changedItems) in changes) { await _sessionManager.SendMessageToUserSessions( [userId], SessionMessageType.UserDataChanged, - () => GetUserDataChangeInfo(userId, changedItems), + () => GetUserDataChangeInfo(userId, changedItems.Values), default).ConfigureAwait(false); } } - private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems) + private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems) { var user = _userManager.GetUserById(userId) ?? throw new ArgumentException("Invalid user ID", nameof(userId)); @@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints { UserId = userId, UserDataList = changedItems - .DistinctBy(x => x.Id) .Select(i => { var dto = _userDataManager.GetUserDataDto(i, user); 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/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 1bf0f8c76c..0f92e2f03e 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -8,6 +8,7 @@ using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -21,6 +22,7 @@ namespace Emby.Server.Implementations.IO private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; + private readonly IDirectoryService _directoryService; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; /// <summary> @@ -47,6 +49,7 @@ namespace Emby.Server.Implementations.IO /// <param name="libraryManager">The library manager.</param> /// <param name="configurationManager">The configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> + /// <param name="directoryService">The directory service.</param> /// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> public LibraryMonitor( @@ -54,6 +57,7 @@ namespace Emby.Server.Implementations.IO ILibraryManager libraryManager, IServerConfigurationManager configurationManager, IFileSystem fileSystem, + IDirectoryService directoryService, IHostApplicationLifetime appLifetime, DotIgnoreIgnoreRule dotIgnoreIgnoreRule) { @@ -61,6 +65,7 @@ namespace Emby.Server.Implementations.IO _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; + _directoryService = directoryService; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; appLifetime.ApplicationStarted.Register(Start); @@ -363,6 +368,8 @@ namespace Emby.Server.Implementations.IO return; } + _directoryService.Invalidate(path); + // Ignore certain files, If the parent of an ignored path has a change event, ignore that too foreach (var i in _tempIgnoredPaths.Keys) { 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..7cae2a671b 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,51 +28,23 @@ namespace Emby.Server.Implementations.Images { var view = (CollectionFolder)item; var viewType = view.CollectionType; + var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType); + var recursive = viewType != CollectionType.playlists; - BaseItemKind[] includeItemTypes; - - switch (viewType) + if (viewType == CollectionType.music) { - 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; + // Music albums usually don't have dedicated backdrops, so use artist instead + includeItemTypes = [BaseItemKind.MusicArtist]; } - var recursive = viewType != CollectionType.playlists; - return view.GetItemList(new InternalItemsQuery { 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/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 6ed417c395..0044fcd4dc 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -15,7 +16,6 @@ using Emby.Naming.Common; using Emby.Naming.TV; using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers; -using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks.Tasks; using Emby.Server.Implementations.Sorting; @@ -35,7 +35,6 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; @@ -45,6 +44,7 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; @@ -74,7 +74,6 @@ namespace Emby.Server.Implementations.Library private readonly Lazy<IProviderManager> _providerManagerFactory; private readonly Lazy<IUserViewManager> _userViewManagerFactory; private readonly IServerApplicationHost _appHost; - private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; private readonly IItemRepository _itemRepository; private readonly IItemPersistenceService _persistenceService; @@ -86,6 +85,8 @@ namespace Emby.Server.Implementations.Library private readonly IPeopleRepository _peopleRepository; private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; + private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly FastConcurrentLru<Guid, BaseItem> _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; @@ -120,7 +121,6 @@ namespace Emby.Server.Implementations.Library /// <param name="fileSystem">The file system.</param> /// <param name="providerManagerFactory">The provider manager.</param> /// <param name="userViewManagerFactory">The user view manager.</param> - /// <param name="mediaEncoder">The media encoder.</param> /// <param name="itemRepository">The item repository.</param> /// <param name="persistenceService">The item persistence service.</param> /// <param name="nextUpService">The next up service.</param> @@ -132,6 +132,7 @@ namespace Emby.Server.Implementations.Library /// <param name="peopleRepository">The people repository.</param> /// <param name="pathManager">The path manager.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> + /// <param name="localization">The localization manager.</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( @@ -145,7 +146,6 @@ namespace Emby.Server.Implementations.Library IFileSystem fileSystem, Lazy<IProviderManager> providerManagerFactory, Lazy<IUserViewManager> userViewManagerFactory, - IMediaEncoder mediaEncoder, IItemRepository itemRepository, IItemPersistenceService persistenceService, INextUpService nextUpService, @@ -157,6 +157,7 @@ namespace Emby.Server.Implementations.Library IPeopleRepository peopleRepository, IPathManager pathManager, DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + ILocalizationManager localization, IMediaStreamRepository mediaStreamRepository, Lazy<IExternalDataManager> externalDataManagerFactory) { @@ -170,7 +171,6 @@ namespace Emby.Server.Implementations.Library _fileSystem = fileSystem; _providerManagerFactory = providerManagerFactory; _userViewManagerFactory = userViewManagerFactory; - _mediaEncoder = mediaEncoder; _itemRepository = itemRepository; _persistenceService = persistenceService; _nextUpService = nextUpService; @@ -184,6 +184,8 @@ namespace Emby.Server.Implementations.Library _peopleRepository = peopleRepository; _pathManager = pathManager; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; + _localization = localization; + _directoryService = directoryService; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -407,6 +409,13 @@ namespace Emby.Server.Implementations.Library } _persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); + + // Evict the deleted items from the cache and announce each removal. + foreach (var (item, _, _) in pathMaps) + { + _cache.TryRemove(item.Id, out _); + ReportItemRemoved(item, item.GetOwner() ?? item.GetParent()); + } } public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem) @@ -606,6 +615,12 @@ namespace Emby.Server.Implementations.Library folder.UserData = null; } + // Announce the descendants before the item itself. + foreach (var child in children) + { + ReportItemRemoved(child, item); + } + ReportItemRemoved(item, parent); } @@ -1192,6 +1207,12 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public Guid GetPersonId(string name) + { + return GetItemByNameId<Person>(Person.GetPath(name)); + } + + /// <inheritdoc /> public Person? GetPerson(string name) { var path = Person.GetPath(name); @@ -1204,6 +1225,33 @@ namespace Emby.Server.Implementations.Library return null; } + /// <inheritdoc /> + public Person GetOrCreatePerson(string name) + { + var existing = GetPerson(name); + if (existing is not null) + { + return existing; + } + + var path = Person.GetPath(name); + var info = Directory.CreateDirectory(path); + var item = new Person + { + Name = name, + Id = GetItemByNameId<Person>(path), + DateCreated = info.CreationTimeUtc, + DateModified = info.LastWriteTimeUtc, + Path = path + }; + + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + CreateItem(item, null); + + return item; + } + /// <summary> /// Gets the studio. /// </summary> @@ -1336,15 +1384,6 @@ namespace Emby.Server.Implementations.Library return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId); } - /// <inheritdoc /> - public Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - // Ensure the location is available. - Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath); - - return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress); - } - /// <summary> /// Reloads the root media folder. /// </summary> @@ -1471,6 +1510,10 @@ namespace Emby.Server.Implementations.Library var numComplete = 0; var numTasks = tasks.Count; + _logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks); + + var phaseStart = Stopwatch.GetTimestamp(); + foreach (var task in tasks) { // Prevent access to modified closure @@ -1488,20 +1531,45 @@ namespace Emby.Server.Implementations.Library progress.Report(innerPercent); }); - _logger.LogDebug("Running post-scan task {0}", task.GetType().Name); + var taskName = task.GetType().Name; + var taskStart = Stopwatch.GetTimestamp(); + + _logger.LogInformation( + "Running post-scan task {TaskNumber}/{TaskCount}: {TaskName}", + currentNumComplete + 1, + numTasks, + taskName); try { await task.Run(innerProgress, cancellationToken).ConfigureAwait(false); + + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogInformation( + "Post-scan task {TaskName} completed after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); } catch (OperationCanceledException) { - _logger.LogInformation("Post-scan task cancelled: {0}", task.GetType().Name); + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogInformation( + "Post-scan task {TaskName} cancelled after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error running post-scan task"); + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogError( + ex, + "Post-scan task {TaskName} failed after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); } numComplete++; @@ -1510,6 +1578,12 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } + var phaseElapsed = Stopwatch.GetElapsedTime(phaseStart); + _logger.LogInformation( + "All post-scan tasks completed after {Minutes} minute(s) and {Seconds} seconds", + Math.Truncate(phaseElapsed.TotalMinutes), + phaseElapsed.Seconds); + _persistenceService.UpdateInheritedValues(); progress.Report(100); @@ -1727,9 +1801,21 @@ namespace Emby.Server.Implementations.Library return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query); } - public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId) + /// <inheritdoc/> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user) { - return _countService.GetChildCountBatch(parentIds, userId); + var query = new InternalItemsQuery(user); + if (user is not null) + { + AddUserToQuery(query, user); + } + + return _countService.GetItemCountsForNameItems(kind, ids, relatedItemKinds, query); + } + + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user) + { + return _countService.GetChildCountBatch(parentIds, user); } /// <inheritdoc/> @@ -1896,14 +1982,14 @@ namespace Emby.Server.Implementations.Library } // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - query.AncestorIds = []; - - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) + var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + if (topParentIds.Length == 0) { - query.TopParentIds = [Guid.NewGuid()]; + return; } + + query.TopParentIds = topParentIds; + query.AncestorIds = []; } public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) @@ -1949,12 +2035,15 @@ namespace Emby.Server.Implementations.Library if (parents.All(i => i is ICollectionFolder || i is UserView)) { // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) + if (topParentIds.Length > 0) { - query.TopParentIds = [Guid.NewGuid()]; + query.TopParentIds = topParentIds; + } + else + { + SetAncestorIds(query, parents); } } else if (parents.Count == 1 && parents.First() is Folder folder @@ -1963,34 +2052,31 @@ namespace Emby.Server.Implementations.Library { // Playlists and BoxSets store their contents in LinkedChildren and never // populate AncestorIds for those items, so a recursive AncestorIds query - // would return zero rows. Resolve to the linked child IDs up front and - // route through the existing indexed ItemIds filter. - query.ItemIds = folder.LinkedChildren - .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) - .Select(lc => lc.ItemId!.Value) - .ToArray(); - - // Empty linked-children should still return empty rather than scanning everything. - if (query.ItemIds.Length == 0) - { - query.ItemIds = [Guid.NewGuid()]; - } + // would return zero rows. Filter by the descendant set instead, which follows + // the links and keeps descending, so a linked folder contributes what is below + // it as well - the episodes of a Series added to a collection, for example. + query.DescendantOfId = folder.Id; } else { - // We need to be able to query from any arbitrary ancestor up the tree - query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray(); - - // Prevent searching in all libraries due to empty filter - if (query.AncestorIds.Length == 0) - { - query.AncestorIds = [Guid.NewGuid()]; - } + SetAncestorIds(query, parents); } query.Parent = null; } + private static void SetAncestorIds(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents) + { + // We need to be able to query from any arbitrary ancestor up the tree + query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray(); + + // Prevent searching in all libraries due to empty filter + if (query.AncestorIds.Length == 0) + { + query.AncestorIds = [Guid.NewGuid()]; + } + } + private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true) { if (query.User is null) @@ -2230,6 +2316,12 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds) + { + return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds); + } + + /// <inheritdoc /> public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType) { _linkedChildrenService.UpsertLinkedChild(parentId, childId, childType); @@ -2315,9 +2407,13 @@ namespace Emby.Server.Implementations.Library { var comparer = Comparers.FirstOrDefault(c => name == c.Type); - // If it requires a user, create a new one, and assign the user if (comparer is IUserBaseItemComparer) { + if (user is null) + { + throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided."); + } + var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances userComparer.User = user; @@ -2367,6 +2463,7 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + altVideo.IsInMixedFolder = video.IsInMixedFolder; // ResolveAlternateVersion only sees the alternate's primary file. // If the alternate is itself a stack (e.g. 1080p part1 + part2), // detect its parts from sibling files so its AdditionalParts persist. @@ -2450,8 +2547,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; @@ -2484,9 +2587,15 @@ namespace Emby.Server.Implementations.Library } } - if (!File.Exists(image.Path)) + if (string.IsNullOrEmpty(image.Path) || !File.Exists(image.Path)) { - _logger.LogWarning("Image not found at {ImagePath}", image.Path); + _logger.LogWarning( + "{ImageType} image for {ItemName} ({ItemId}) not found at \"{ImagePath}\", source was {SourcePath}", + img.Type, + item.Name, + item.Id, + image.Path, + img.Path); continue; } @@ -2546,6 +2655,8 @@ namespace Emby.Server.Implementations.Library item.DateLastSaved = DateTime.UtcNow; } + ForgetDroppedLocalAlternateVersions(items); + // Resolve and add any local alternate version items that don't exist yet // This ensures they exist in the database when LinkedChildren are processed var allItems = new List<BaseItem>(items); @@ -2574,6 +2685,7 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + altVideo.IsInMixedFolder = video.IsInMixedFolder; // ResolveAlternateVersion only sees the alternate's primary file. // If the alternate is itself a stack (e.g. 1080p part1 + part2), // detect its parts from sibling files so its AdditionalParts persist. @@ -2634,6 +2746,30 @@ namespace Emby.Server.Implementations.Library public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) => UpdateItemsAsync([item], parent, updateReason, cancellationToken); + /// <summary> + /// Forgets the cached local alternate versions of the supplied items that they no longer list. + /// </summary> + /// <param name="items">The items about to be saved.</param> + private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items) + { + foreach (var video in items.OfType<Video>()) + { + var videoType = video.GetType(); + var keptIds = video.LocalAlternateVersions + .Where(path => !string.IsNullOrEmpty(path)) + .Select(path => GetNewItemId(path, videoType)) + .ToHashSet(); + + foreach (var versionId in GetLocalAlternateVersionIds(video)) + { + if (!keptIds.Contains(versionId)) + { + _cache.TryRemove(versionId, out _); + } + } + } + } + /// <inheritdoc /> public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken) { @@ -2857,7 +2993,8 @@ namespace Emby.Server.Implementations.Library "views", _fileSystem.GetValidFilename(viewType.ToString())); - var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView)); + // The display name is localized, so it must not take part in the id. + var id = GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView)); var item = GetItemById(id) as UserView; @@ -2881,6 +3018,13 @@ namespace Emby.Server.Implementations.Library refresh = true; } + else if (!string.Equals(item.Name, name, StringComparison.Ordinal)) + { + item.Name = name; + item.ForcedSortName = sortName; + + refresh = true; + } if (refresh) { @@ -2901,7 +3045,9 @@ namespace Emby.Server.Implementations.Library var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); - var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + + // The name is either localized (grouped views) or the library folder's own name. + var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); var id = GetNewItemId(idValues, typeof(UserView)); @@ -2931,6 +3077,11 @@ namespace Emby.Server.Implementations.Library isNew = true; } + else if (!string.Equals(item.Name, name, StringComparison.Ordinal)) + { + item.Name = name; + item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } var lastRefreshedUtc = item.DateLastRefreshed; var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval; @@ -3032,7 +3183,7 @@ namespace Emby.Server.Implementations.Library var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); - var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + var idValues = "37_namedview_" + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); if (!string.IsNullOrEmpty(uniqueId)) { idValues += uniqueId; @@ -3066,9 +3217,10 @@ namespace Emby.Server.Implementations.Library isNew = true; } - if (viewType != item.ViewType) + if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal)) { item.ViewType = viewType; + item.Name = name; item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); } @@ -3189,11 +3341,11 @@ namespace Emby.Server.Implementations.Library } } - if (!episode.ProductionYear.HasValue) + if (episode.ProductionYear is null) { episode.ProductionYear = episodeInfo.Year; - if (episode.ProductionYear.HasValue) + if (episode.ProductionYear is not null) { changed = true; } @@ -3270,9 +3422,11 @@ namespace Emby.Server.Implementations.Library var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); if (ownerVideoInfo is null) { - yield break; + return []; } + var candidates = new List<ExtraCandidate>(); + var count = filtered.Count; for (var i = 0; i < count; i++) { @@ -3286,35 +3440,50 @@ namespace Emby.Server.Implementations.Library foreach (var file in filesInSubFolderList) { - if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType)) + if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { continue; } - var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder); - if (extra is not null) - { - yield return extra; - } + AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder); } } - else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType)) + else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { - var extra = GetExtra(current, extraType.Value, false); - if (extra is not null) - { - yield return extra; - } + AddCandidate(current, extraType.Value, extraRule, false); } } - BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder) + var extras = new List<BaseItem>(); + var typeCounters = new Dictionary<ExtraType, int>(); + + // Order by path so that the numbering handed out below does not depend on the + // order the file system happened to list the folder in + foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal)) + { + var extra = PrepareExtra(candidate); + if (extra is not null) + { + extras.Add(extra); + } + } + + return extras; + + void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder) { var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType)); - if (extra is not Video && extra is not Audio) + if (extra is Video or Audio) { - return null; + candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder)); } + } + + BaseItem? PrepareExtra(ExtraCandidate candidate) + { + var resolved = candidate.Extra; + var extra = resolved; + var name = GetExtraName(candidate, ownerVideoInfo, typeCounters); // Try to retrieve it from the db. If we don't find it, use the resolved version var itemById = GetItemById(extra.Id); @@ -3323,10 +3492,18 @@ namespace Emby.Server.Implementations.Library extra = itemById; } + // An extra is named after its file, so the file is the source of truth. Items created + // by older versions, or renamed by a metadata provider, are corrected here; + // RefreshExtras persists the change. + if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true) + { + extra.Name = name; + } + // Only update extra type if it is more specific then the currently known extra type - if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown) + if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown) { - extra.ExtraType = extraType; + extra.ExtraType = candidate.ExtraType; } // Only return items that are actual extras (have ExtraType set) @@ -3334,7 +3511,7 @@ namespace Emby.Server.Implementations.Library // so that RefreshExtras can detect when they need updating and set ForceSave. if (extra.ExtraType is not null) { - extra.IsInMixedFolder = isInMixedFolder; + extra.IsInMixedFolder = candidate.IsInMixedFolder; return extra; } @@ -3342,6 +3519,57 @@ namespace Emby.Server.Implementations.Library } } + /// <summary> + /// Gets the name to give an extra. + /// </summary> + /// <param name="candidate">The resolved extra.</param> + /// <param name="ownerVideoInfo">The naming info of the owner.</param> + /// <param name="typeCounters">Number of extras named after their type so far, per type.</param> + /// <returns>The name.</returns> + private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters) + { + var isNamedAfterOwner = candidate.ExtraRule.RuleType switch + { + ExtraRuleType.Filename => true, + ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase), + _ => false + }; + + if (!isNamedAfterOwner) + { + return candidate.Extra.Name; + } + + typeCounters.TryGetValue(candidate.ExtraType, out var seen); + typeCounters[candidate.ExtraType] = seen + 1; + + var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType)); + + return seen == 0 + ? typeName + : string.Format( + CultureInfo.InvariantCulture, + _localization.GetServerLocalizedString("NameExtraNumbered"), + typeName, + seen + 1); + } + + private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch + { + ExtraType.Clip => "NameExtraClip", + ExtraType.Trailer => "NameExtraTrailer", + ExtraType.BehindTheScenes => "NameExtraBehindTheScenes", + ExtraType.DeletedScene => "NameExtraDeletedScene", + ExtraType.Interview => "NameExtraInterview", + ExtraType.Scene => "NameExtraScene", + ExtraType.Sample => "NameExtraSample", + ExtraType.ThemeSong => "NameExtraThemeSong", + ExtraType.ThemeVideo => "NameExtraThemeVideo", + ExtraType.Featurette => "NameExtraFeaturette", + ExtraType.Short => "NameExtraShort", + _ => "NameExtraUnknown" + }; + public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem) { foreach (var map in _configurationManager.Configuration.PathSubstitutions) @@ -3413,11 +3641,23 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc/> + public int DeleteOrphanedCredits() + { + return _peopleRepository.DeleteOrphanedCredits(); + } + + /// <inheritdoc/> public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes) { return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes); } + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds) + { + return _peopleRepository.GetPeopleByItems(itemIds); + } + public void UpdatePeople(BaseItem item, List<PersonInfo> people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); @@ -3451,7 +3691,20 @@ namespace Emby.Server.Implementations.Library await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - return item.GetImageInfo(image.Type, imageIndex); + var localImage = item.GetImageInfo(image.Type, imageIndex); + if (localImage is null) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Downloaded {0} image {1} from {2} is not attached to {3} ({4})", + image.Type, + imageIndex, + url, + item.Name, + item.Id)); + } + + return localImage; } catch (HttpRequestException ex) { @@ -3473,7 +3726,13 @@ namespace Emby.Server.Implementations.Library await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); } - throw new InvalidOperationException("Unable to convert any images to local"); + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Unable to convert any {0} image url in \"{1}\" to a local file for {2} ({3})", + image.Type, + image.Path, + item.Name, + item.Id)); } public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary) @@ -3529,6 +3788,10 @@ namespace Emby.Server.Implementations.Library AddMediaPathInternal(name, path, false); } } + + // The libraries root was listed before this folder existed, so drop that listing: + // anything still reading it resolves the library set without the new folder. + _directoryService.Invalidate(virtualFolderPath); } finally { @@ -3555,27 +3818,14 @@ namespace Emby.Server.Implementations.Library var itemUpdateType = ItemUpdateType.MetadataDownload; var saveEntity = false; - var createEntity = false; var personEntity = GetPerson(person.Name); if (personEntity is null) { try { - var path = Person.GetPath(person.Name); - var info = Directory.CreateDirectory(path); - personEntity = new Person() - { - Name = person.Name, - Id = GetItemByNameId<Person>(path), - DateCreated = info.CreationTimeUtc, - DateModified = info.LastWriteTimeUtc, - Path = path - }; - - personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); + personEntity = GetOrCreatePerson(person.Name); saveEntity = true; - createEntity = true; } catch (Exception ex) { @@ -3609,11 +3859,6 @@ namespace Emby.Server.Implementations.Library if (saveEntity) { - if (createEntity) - { - CreateItems([personEntity], null, CancellationToken.None); - } - await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); personEntity.DateLastSaved = DateTime.UtcNow; @@ -3653,7 +3898,9 @@ namespace Emby.Server.Implementations.Library } var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName) + ?? throw new FileNotFoundException( + string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); CreateShortcut(virtualFolderPath, pathInfo); @@ -3674,7 +3921,9 @@ namespace Emby.Server.Implementations.Library ArgumentNullException.ThrowIfNull(mediaPath); var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName) + ?? throw new FileNotFoundException( + string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -3713,9 +3962,9 @@ namespace Emby.Server.Implementations.Library var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var path = Path.Combine(rootFolderPath, name); + var path = FileSystemHelper.GetChildPath(rootFolderPath, name); - if (!Directory.Exists(path)) + if (path is null || !Directory.Exists(path)) { throw new FileNotFoundException("The media folder does not exist"); } @@ -3725,6 +3974,7 @@ namespace Emby.Server.Implementations.Library try { Directory.Delete(path, true); + _directoryService.Invalidate(path); } finally { @@ -3779,9 +4029,9 @@ namespace Emby.Server.Implementations.Library ArgumentException.ThrowIfNullOrEmpty(mediaPath); var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName); - if (!Directory.Exists(virtualFolderPath)) + if (virtualFolderPath is null || !Directory.Exists(virtualFolderPath)) { throw new FileNotFoundException( string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); @@ -3794,6 +4044,7 @@ namespace Emby.Server.Implementations.Library if (!string.IsNullOrEmpty(shortcut)) { _fileSystem.DeleteFile(shortcut); + _directoryService.Invalidate(shortcut); } var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -3837,6 +4088,7 @@ namespace Emby.Server.Implementations.Library } _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); + _directoryService.Invalidate(lnk); RemoveContentTypeOverrides(path); } @@ -3880,5 +4132,19 @@ namespace Emby.Server.Implementations.Library { return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType); + } + + private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder); } } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 9ccfefa86e..97e00177b6 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -229,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 />> @@ -386,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); @@ -400,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; @@ -540,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/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 7591359ea4..7d0f3900c5 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("String can't be empty.", nameof(attribute)); } - var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc. + // The code below only supports aliases for attributes in the form of "<alias>id". + ReadOnlySpan<char> shortAttr = attribute switch + { + _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb", + _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb", + _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb", + _ => ReadOnlySpan<char>.Empty + }; - // Must be at least 3 characters after the attribute =, ], any character, - // then we offset it by 1, because we want the index and not length. - var maxIndex = str.Length - attribute.Length - 2; - while (attributeIndex > -1 && attributeIndex < maxIndex) + for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;) { - var attributeEnd = attributeIndex + attribute.Length; + // We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'. + var subStr = str[strIndex..]; + int attributeEnd = 0; + + if (shortAttr.Length > 0) + { + // If we are using an alias it should be shorter (and a prefix), so let's search for that. + attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + shortAttr.Length; + } + else + { + attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + attribute.Length; + } + + // The next iteration should start at the end of the attribute we just found. + // If attributeIndex < 0, the loop will end and strIndex won't be used again. + strIndex += attributeEnd; + if (attributeIndex > 0) { - var attributeOpener = str[attributeIndex - 1]; + var attributeOpener = subStr[attributeIndex - 1]; var attributeCloser = attributeOpener switch { '[' => ']', @@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library '{' => '}', _ => '\0' }; - if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-')) + + if (attributeCloser != '\0') { - var closingIndex = str[attributeEnd..].IndexOf(attributeCloser); + if (shortAttr.Length > 0 + && attributeEnd + 1 < subStr.Length + && (subStr[attributeEnd] is 'i' or 'I') + && (subStr[attributeEnd + 1] is 'd' or 'D')) + { + // We were searching for a shortened attribute, but it's followed by "id" - let's skip it. + attributeEnd += 2; + } - // Must be at least 1 character before the closing bracket. - if (closingIndex > 1) + // attributeEnd points at '='. + // We need at least 1 more character and the closing bracket after that. + if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-')) { - return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); + var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser); + + // Must be at least 1 character before the closing bracket. + if (closingIndex > 1) + { + var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim(); + + if (trimmed.Length > 0) + { + return trimmed.ToString(); + } + } } } } - - str = str[attributeEnd..]; - attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); } // for imdbid we also accept pattern matching @@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library return match ? imdbId.ToString() : null; } - // Allow tmdb as an alias for tmdbid - if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase)) - { - var tmdbValue = str.GetAttributeValue("tmdb"); - if (tmdbValue is not null) - { - return tmdbValue; - } - } - return null; } diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs index fad948ad97..2a50fcc7fe 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -43,7 +44,19 @@ public class PathManager : IPathManager public string? GetAttachmentPath(string mediaSourceId, string fileName) { var folder = GetAttachmentFolderPath(mediaSourceId); - return folder is null ? null : Path.Combine(folder, fileName); + if (folder is null) + { + return null; + } + + var safeName = PathHelper.GetSafeLeafFileName(fileName); + if (safeName is null) + { + _logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId); + return null; + } + + return Path.Combine(folder, safeName); } /// <inheritdoc /> 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/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs index b9f9f29723..a0f75e4ddb 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers : base(logger, namingOptions, directoryService) { _namingOptions = namingOptions; - _trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) }; - _videoResolvers = new IItemResolver[] { this }; + _trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)]; + _videoResolvers = [this]; } protected override Video Resolve(ItemResolveArgs args) @@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers _ => _videoResolvers }; - public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "") + public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "") { var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot); - if (extraResult.ExtraType is null) + if (extraResult.ExtraType is null || extraResult.Rule is null) { extraType = null; + extraRule = null; return false; } @@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } extraType = extraResult.ExtraType; + extraRule = extraResult.Rule; return isValid; } diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs index ba320266a4..b3bdea704a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs @@ -2,6 +2,7 @@ using Emby.Naming.Common; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -14,15 +15,25 @@ namespace Emby.Server.Implementations.Library.Resolvers public class GenericVideoResolver<T> : BaseVideoResolver<T> where T : Video, new() { + private readonly bool _parseName; + /// <summary> /// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> - public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService) + /// <param name="parseName">Whether to parse the file name for metadata such as the year.</param> + public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService, bool parseName = false) : base(logger, namingOptions, directoryService) { + _parseName = parseName; + } + + /// <inheritdoc /> + protected override T Resolve(ItemResolveArgs args) + { + return ResolveVideo<T>(args, _parseName); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 68b66ab7f5..80375ae12d 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies // We need to only look at the name of this actual item (not parents) var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan()); - var tmdbid = justName.GetAttributeValue("tmdbid"); + // The fallback filename is only used when the item isn't in a mixed folder + var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan()); - // If not in a mixed folder and ID not found in folder path, check filename - if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder) + item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid")); + item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid")); + + string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute) { - tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid"); - } + var id = name.GetAttributeValue(attribute); + + // If not in a mixed folder and ID not found in folder path, check filename + if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder) + { + id = fallbackName.GetAttributeValue(attribute); + } - item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid); + return id; + } if (!string.IsNullOrEmpty(item.Path)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 14798dda65..d6513fc79c 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -1,8 +1,10 @@ #nullable disable using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; @@ -45,8 +47,30 @@ namespace Emby.Server.Implementations.Library.Resolvers }; } + // Anything directly inside the internal playlists folder is a playlist, even when its + // playlist.xml is missing: failing to resolve here makes the library scan treat the + // playlist as deleted from disk and remove it, taking its items with it. + if (args.Parent is PlaylistsFolder) + { + return new Playlist + { + Path = args.Path, + Name = filename, + OpenAccess = true + }; + } + // 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/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 6e9a38fd34..a8bd832cc8 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV args.LibraryOptions.SeasonZeroDisplayName : string.Format( CultureInfo.InvariantCulture, - _localization.GetLocalizedString("NameSeasonNumber"), + _localization.GetServerLocalizedString("NameSeasonNumber"), seasonNumber, args.LibraryOptions.PreferredMetadataLanguage); } @@ -129,6 +129,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV var tmdbId = justName.GetAttributeValue("tmdbid"); item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); + + // Anime databases model a single cour as its own entry, so a multi-season + // series maps to one of these ids per season rather than one per series. + var anidbId = justName.GetAttributeValue("anidbid"); + item.TrySetProviderId("AniDB", anidbId); + + var aniListId = justName.GetAttributeValue("anilistid"); + item.TrySetProviderId("AniList", aniListId); + + var aniSearchId = justName.GetAttributeValue("anisearchid"); + item.TrySetProviderId("AniSearch", aniSearchId); } } } 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..306a8673d5 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -0,0 +1,453 @@ +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); + + // 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. Filter them here 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 (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) + { + var user = _userManager.GetUserById(query.UserId.Value); + if (user is not null) + { + externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).ConfigureAwait(false); + } + } + + if (externalResults.Count > 0) + { + return externalResults; + } + + if (_internalProviders.Length > 0) + { + _logger.LogDebug("No results from external providers, using internal provider results"); + } + + return await internalTask.ConfigureAwait(false); + } + + private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync( + IReadOnlyList<SearchResult> candidates, + User user, + SearchProviderQuery query, + CancellationToken cancellationToken) + { + // SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds + // for the user's accessible libraries. The candidate ids are applied to the query below + // rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when + // ItemIds is non-empty. + var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager); + + 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 allowedIds = await baseQuery + .Select(e => e.Id) + .ToHashSetAsync(cancellationToken) + .ConfigureAwait(false); + + if (allowedIds.Count == candidates.Count) + { + return candidates; + } + + 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/SearchQueryAccessFilter.cs b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs new file mode 100644 index 0000000000..6e3f01de13 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs @@ -0,0 +1,38 @@ +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.Search; + +/// <summary> +/// Builds the access filter that decides which items a search may return for a user. +/// </summary> +internal static class SearchQueryAccessFilter +{ + /// <summary> + /// Builds an access filter carrying the search's library access and type filters. + /// </summary> + /// <param name="user">The user the search runs for.</param> + /// <param name="query">The search query.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The access filter.</returns> + public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager) + { + // The type filters have to travel with the access filter: a by-name item belongs to no + // library, so it carries no TopParentId to match, and the library filter only knows to + // exempt it when the query says those types are wanted. A search scoped to a parent gets + // no exemption because a by-name item has no parent to descend from either. + var accessFilter = new InternalItemsQuery(user) + { + IncludeItemTypes = query.IncludeItemTypes, + ExcludeItemTypes = query.ExcludeItemTypes, + IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty() + }; + + // ConfigureUserAccess populates TopParentIds for the libraries the user may open. + libraryManager.ConfigureUserAccess(accessFilter, user); + + return accessFilter; + } +} diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs new file mode 100644 index 0000000000..c4d3b249d5 --- /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); + + // 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, + SearchProviderQuery searchQuery) + { + var userId = searchQuery.UserId; + if (!userId.HasValue || userId.Value.IsEmpty()) + { + return query; + } + + var user = _userManager.GetUserById(userId.Value); + if (user is null) + { + return query; + } + + var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager); + 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 b4ed12a20c..b1547e72fe 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -53,6 +53,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie 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. @@ -60,14 +61,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie /// <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( IDbContextFactory<JellyfinDbContext> dbProvider, IItemQueryHelpers queryHelpers, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager) { _dbProvider = dbProvider; _queryHelpers = queryHelpers; _serverConfigurationManager = serverConfigurationManager; + _libraryManager = libraryManager; } /// <inheritdoc/> @@ -156,6 +160,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie 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); @@ -251,7 +260,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie } var candidateRows = await context.ItemValuesMap.AsNoTracking() - .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Where(m => !m.Item.PrimaryVersionId.HasValue && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) .ToListAsync(cancellationToken).ConfigureAwait(false); @@ -267,6 +276,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie if (personSourceRows.Count > 0) { var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => !m.Item.PrimaryVersionId.HasValue) .Where(m => context.PeopleBaseItemMap .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) .Select(s => s.PeopleId) diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs new file mode 100644 index 0000000000..75aea0eab6 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs @@ -0,0 +1,42 @@ +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Builds the access filter that decides which items a similar-items lookup may return for a user. +/// </summary> +internal static class SimilarItemsAccessFilter +{ + private static readonly BaseItemKind[] _itemByNameKinds = + [ + BaseItemKind.Person, + BaseItemKind.Genre, + BaseItemKind.MusicGenre, + BaseItemKind.MusicArtist, + BaseItemKind.Studio + ]; + + /// <summary> + /// Builds an access filter carrying the user's library access and parental restrictions. + /// </summary> + /// <param name="user">The user the lookup runs for.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The access filter.</returns> + public static InternalItemsQuery Build(User user, ILibraryManager libraryManager) + { + // IncludeItemTypes is read only for the by-name exemption here; the caller applies this + // filter through ApplyAccessFiltering, which does not translate it into a type restriction. + var accessFilter = new InternalItemsQuery(user) + { + IncludeItemTypes = _itemByNameKinds + }; + + // ConfigureUserAccess populates TopParentIds for the libraries the user may open. + libraryManager.ConfigureUserAccess(accessFilter, user); + + return accessFilter; + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index d923cff07e..fd5f292ebe 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -7,8 +7,10 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -16,11 +18,13 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.SimilarItems; @@ -35,6 +39,8 @@ public class SimilarItemsManager : ISimilarItemsManager private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; private ISimilarItemsProvider[] _similarItemsProviders = []; /// <summary> @@ -45,18 +51,24 @@ public class SimilarItemsManager : ISimilarItemsManager /// <param name="libraryManager">The library manager.</param> /// <param name="fileSystem">The file system.</param> /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="queryHelpers">The shared item query helpers.</param> public SimilarItemsManager( ILogger<SimilarItemsManager> logger, IServerApplicationPaths appPaths, ILibraryManager libraryManager, IFileSystem fileSystem, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemQueryHelpers queryHelpers) { _logger = logger; _appPaths = appPaths; _libraryManager = libraryManager; _fileSystem = fileSystem; _serverConfigurationManager = serverConfigurationManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; } /// <inheritdoc/> @@ -183,6 +195,7 @@ public class SimilarItemsManager : ISimilarItemsManager // Collect references in batches and resolve against local library. // Stop fetching once we have enough resolved local items. const int BatchSize = 20; + const int MaxRemoteReferenceFetchLimit = 500; var remaining = requestedLimit - allResults.Count; var collectedReferences = new List<SimilarItemReference>(); var pendingBatch = new List<SimilarItemReference>(); @@ -199,7 +212,7 @@ public class SimilarItemsManager : ISimilarItemsManager remaining -= resolvedItems.Count; pendingBatch.Clear(); - if (remaining <= 0) + if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit) { break; } @@ -229,11 +242,64 @@ public class SimilarItemsManager : ISimilarItemsManager } } - return allResults + var ordered = allResults .OrderByDescending(x => x.Score) .Select(x => x.Item) .Take(requestedLimit) .ToList(); + + return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false); + } + + private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync( + IReadOnlyList<BaseItem> candidates, + User? user, + CancellationToken cancellationToken) + { + if (candidates.Count == 0 || user is null) + { + return candidates; + } + + var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager); + + // No accessible libraries means nothing to compare against, and an empty TopParentIds set + // would disable the filter rather than reject everything. + if (accessFilter.TopParentIds.Length == 0) + { + return candidates; + } + + Guid[] candidateIds = [.. candidates.Select(c => c.Id)]; + + 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.Id)).ToList(); + _logger.LogDebug( + "Dropped {Dropped} of {Total} similar-item candidates due to user access filtering", + candidates.Count - filtered.Count, + candidates.Count); + + return filtered; + } } /// <inheritdoc/> @@ -375,19 +441,39 @@ public class SimilarItemsManager : ISimilarItemsManager var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false); + // Filter once across every category rather than per baseline, so a batch provider costs one + // access query no matter how many categories it produced. + var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList(); + var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false); + + HashSet<Guid>? allowedIds = allowed.Count == allItems.Count + ? null + : [.. allowed.Select(item => item.Id)]; + var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count); foreach (var baseline in baselineItems) { - if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0) + if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0) + { + continue; + } + + if (allowedIds is not null) { - recommendations.Add(new SimilarItemsRecommendation + similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList(); + if (similar.Count == 0) { - BaselineItemName = baseline.Name, - CategoryId = baseline.Id, - RecommendationType = recommendationType, - Items = similar - }); + continue; + } } + + recommendations.Add(new SimilarItemsRecommendation + { + BaselineItemName = baseline.Name, + CategoryId = baseline.Id, + RecommendationType = recommendationType, + Items = similar + }); } return recommendations; diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 1281f1587f..0680046c11 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library } else { - var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault(); + var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + var userData = userDataRow is not null ? Map(userDataRow) : null; if (userData is not null) { result[item.Id] = userData; @@ -211,36 +212,128 @@ namespace Emby.Server.Implementations.Library return result; } - // Build a single query for all missing items + // Build a single query for all missing items. Fetch rows by item alone so rows kept + // under keys from older metadata resolve the same way as the in-memory path. var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList(); - var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList(); - if (allKeys.Count > 0) - { - using var context = _repository.CreateDbContext(); - var userDataArray = context.UserData - .AsNoTracking() - .Where(e => e.UserId.Equals(user.Id)) - .WhereOneOrMany(allItemIds, e => e.ItemId) - .WhereOneOrMany(allKeys, e => e.CustomDataKey) - .ToArray(); - - var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); - foreach (var (item, keys) in itemsNeedingQuery) + using var context = _repository.CreateDbContext(); + var userDataArray = context.UserData + .AsNoTracking() + .Where(e => e.UserId.Equals(user.Id)) + .WhereOneOrMany(allItemIds, e => e.ItemId) + .ToArray(); + + var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); + foreach (var (item, keys) in itemsNeedingQuery) + { + UserItemData userData; + if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) { - UserItemData userData; - if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) - { - var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N")); - userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First()); - } - else - { - userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; - } + userData = Map(ResolveUserDataRow(item, itemUserData)!); + } + else + { + userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; + } + + result[item.Id] = userData; + var cacheKey = GetCacheKey(user.InternalId, item.Id); + _cache.AddOrUpdate(cacheKey, userData); + } + + 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>(); - result[item.Id] = userData; - var cacheKey = GetCacheKey(user.InternalId, item.Id); - _cache.AddOrUpdate(cacheKey, userData); + // 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) + .WhereOneOrMany(localProbeIds, lc => 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]); } } @@ -259,12 +352,41 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public UserItemData? GetUserData(User user, BaseItem item) { - return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() + ArgumentNullException.ThrowIfNull(user); + var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + return row is not null ? Map(row) : new UserItemData() { Key = item.GetUserDataKeys()[0], }; } + /// <summary> + /// Picks the row matching the item's current user data keys, in key order, so rows left behind + /// under keys from older metadata don't take priority over the rows the write path updates. + /// </summary> + /// <param name="item">The item whose keys to match.</param> + /// <param name="rows">The candidate user data rows for a single user.</param> + /// <returns>The best matching row, or <c>null</c> when there are none.</returns> + private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows) + { + var candidates = rows?.ToList(); + if (candidates is null || candidates.Count == 0) + { + return null; + } + + foreach (var key in item.GetUserDataKeys()) + { + var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal)); + if (match is not null) + { + return match; + } + } + + return candidates[0]; + } + /// <inheritdoc /> public UserItemDataDto? GetUserDataDto(BaseItem item, User user) => GetUserDataDto(item, null, user, new DtoOptions()); @@ -281,6 +403,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 +511,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/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 9512b0ffd7..47b3891901 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library if (_config.Configuration.EnableFolderView) { - var name = _localizationManager.GetLocalizedString("Folders"); + var name = _localizationManager.GetServerLocalizedString("Folders"); list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty)); } @@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName) { - var name = _localizationManager.GetLocalizedString(localizationKey); + var name = _localizationManager.GetServerLocalizedString(localizationKey); return GetUserSubViewWithName(name, parentId, type, sortName); } @@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library return GetUserView((Folder)parents[0], viewType, string.Empty); } - var name = _localizationManager.GetLocalizedString(localizationKey); + var name = _localizationManager.GetServerLocalizedString(localizationKey); return _libraryManager.GetNamedView(user, name, viewType, sortName); } @@ -396,6 +396,12 @@ namespace Emby.Server.Implementations.Library query.Limit = limit; return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies); } + + if (collectionType is null) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown); + } } return _libraryManager.GetItemList(query, parents); diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index fa7112eb90..690466be70 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; @@ -61,6 +62,9 @@ public class ArtistsValidator var count = names.Count; var refreshed = 0; + var liveIds = new HashSet<Guid>(); + var unresolved = 0; + foreach (var name in names) { try @@ -73,13 +77,20 @@ public class ArtistsValidator // Fall back to GetArtist if not found (creates new item if needed) item ??= _libraryManager.GetArtist(name); - var isNew = !existingArtistIds.Contains(item.Id); - var neverRefreshed = item.DateLastRefreshed == default; - if (isNew || neverRefreshed) + // A name with no item is nothing to refresh, and nothing to keep alive either. + if (item is not null) { - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); - refreshed++; + liveIds.Add(item.Id); + + var isNew = !existingArtistIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; + + if (isNew || neverRefreshed) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } } catch (OperationCanceledException) @@ -88,6 +99,7 @@ public class ArtistsValidator } catch (Exception ex) { + unresolved++; _logger.LogError(ex, "Error refreshing {ArtistName}", name); } @@ -101,13 +113,26 @@ public class ArtistsValidator _logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count); + // Every name that threw is a name whose artist is missing from the live set, and deleting against + // a live set with holes in it deletes artists the library still refers to. Leave the sweep to a + // run that got a clean read of them. + if (unresolved > 0) + { + _logger.LogWarning( + "Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run", + unresolved, + count); + + progress.Report(100); + return; + } + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicArtist], - IsDeadArtist = true, IsLocked = false - }).Cast<MusicArtist>() - .Where(item => item.IsAccessedByName) + }).OfType<MusicArtist>() + .Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id)) .ToList(); foreach (var item in deadEntities) diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs new file mode 100644 index 0000000000..2cfa446862 --- /dev/null +++ b/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.Validators; + +/// <summary> +/// Ensures top-level library folders have a primary poster after scans. +/// Poster extraction is attempted before library scanning. When a library is +/// empty at that point, no poster can be extracted. This post-scan task reruns +/// metadata extraction for top-level folders that are still missing images. +/// </summary> +public class CollectionPosterVerifyPostScanTask : ILibraryPostScanTask +{ + private readonly ILibraryManager _libraryManager; + private readonly ILogger<CollectionPosterVerifyPostScanTask> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="CollectionPosterVerifyPostScanTask" /> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="logger">The logger.</param> + public CollectionPosterVerifyPostScanTask( + ILibraryManager libraryManager, + ILogger<CollectionPosterVerifyPostScanTask> logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + /// <summary> + /// Runs the specified progress. + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) + { + var libraries = _libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().ToList(); + var totalLibraries = libraries.Count; + var processedLibraries = 0; + + foreach (var library in libraries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!library.HasImage(ImageType.Primary)) + { + _logger.LogDebug("Library {LibraryName} is missing a primary image. Refreshing metadata.", library.Name); + await library.RefreshMetadata(cancellationToken).ConfigureAwait(false); + } + + processedLibraries++; + progress.Report((double)processedLibraries / totalLibraries * 100); + } + + progress.Report(100); + } +} diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index dacef102dd..7d53f40ce7 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -1,12 +1,12 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators; @@ -17,104 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators; public class PeopleValidator { /// <summary> - /// The _library manager. + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; /// <summary> - /// The _logger. + /// The logger. /// </summary> - private readonly ILogger _logger; - - private readonly IFileSystem _fileSystem; + private readonly ILogger<PeopleValidator> _logger; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidator" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem) + public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger) { _libraryManager = libraryManager; _logger = logger; - _fileSystem = fileSystem; } /// <summary> /// Validates the people. /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress) + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { - var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); - - var numComplete = 0; + // Before the refresh below walks them: a credit no item maps to any more stands for nothing, + // and while it is there the person it names cannot reach the dead-person sweep either. + var numOrphaned = _libraryManager.DeleteOrphanedCredits(); + if (numOrphaned > 0) + { + _logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned); + } - var numPeople = people.Count; + var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); + var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Person] + }).ToHashSet(); - IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); + var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds); - _logger.LogDebug("Will refresh {Amount} people", numPeople); + var numComplete = 0; + var count = names.Count; + var refreshed = 0; - foreach (var person in people) + foreach (var name in names) { cancellationToken.ThrowIfCancellationRequested(); try { - var item = _libraryManager.GetPerson(person); - if (item is null) - { - _logger.LogWarning("Failed to get person: {Name}", person); - continue; - } + var item = _libraryManager.GetOrCreatePerson(name); + var isNew = !existingPersonIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + if (isNew || neverRefreshed) { - ImageRefreshMode = MetadataRefreshMode.ValidationOnly, - MetadataRefreshMode = MetadataRefreshMode.ValidationOnly - }; - - await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { + // Don't clutter the log throw; } catch (Exception ex) { - _logger.LogError(ex, "Error validating IBN entry {Person}", person); + _logger.LogError(ex, "Error refreshing {PersonName}", name); } - // Update progress numComplete++; double percent = numComplete; - percent /= numPeople; + percent /= count; + percent *= 100; - subProgress.Report(100 * percent); + progress.Report(percent); } - var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Person], - IsDeadPerson = true, - IsLocked = false - }); + _logger.LogInformation( + "Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet", + refreshed, + count, + newNames.Count); - subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); + // A person somebody locked is theirs, not ours, however little the library still credits them. + var deadEntities = deadIds + .Select(_libraryManager.GetItemById) + .OfType<Person>() + .Where(item => !item.IsLocked) + .ToList(); - var i = 0; - foreach (var item in deadEntities.Chunk(500)) + foreach (var item in deadEntities) { - _libraryManager.DeleteItemsUnsafeFast(item, true); - subProgress.Report(100f / deadEntities.Count * (i++ * 100)); + _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); + } + + /// <summary> + /// Splits the person items into the ones a credit still calls for and the ones nothing does. + /// </summary> + /// <param name="creditNames">Every name credited on an item, from the people table.</param> + /// <param name="getPersonId">Maps a credit name to the id its person item has.</param> + /// <param name="existingPersonIds">The ids of the person items that exist.</param> + /// <returns>The credits needing an item, and the ids of the items nothing credits.</returns> + internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId( + IReadOnlyList<string> creditNames, + Func<string, Guid> getPersonId, + IReadOnlySet<Guid> existingPersonIds) + { + ArgumentNullException.ThrowIfNull(creditNames); + ArgumentNullException.ThrowIfNull(getPersonId); + ArgumentNullException.ThrowIfNull(existingPersonIds); + + var newNames = new List<string>(); + var liveIds = new HashSet<Guid>(); + + foreach (var name in creditNames) + { + var personId = getPersonId(name); + + // Distinct credit names can normalize onto one id; only the first of them needs an item. + if (liveIds.Add(personId) && !existingPersonIds.Contains(personId)) + { + newNames.Add(name); + } + } + + var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList(); - _logger.LogInformation("People validation complete"); + return (newNames, deadIds); } } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 17af935562..2dc4f5652b 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -100,7 +100,7 @@ "TaskAudioNormalization": "تطبيع الصوت", "TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.", "TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة", - "TaskDownloadMissingLyricsDescription": "ينزّل الكلمات للأغاني.", + "TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني", "TaskExtractMediaSegments": "فحص مقاطع المحتوى", "TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).", "TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم", "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.", "Original": "فريد", - "LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}" + "LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}", + "NameExtraBehindTheScenes": "خلف المشاهد", + "NameExtraClip": "مقطع", + "NameExtraDeletedScene": "المشهد المحذوف", + "NameExtraInterview": "مقابلة", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "عيّنة", + "NameExtraScene": "مشهد", + "NameExtraShort": "قصير", + "NameExtraThemeSong": "الاغنية السمة", + "NameExtraThemeVideo": "الفيديو السمة", + "NameExtraFeaturette": "فيلم قصير إضافي", + "NameExtraTrailer": "إعلان ترويجي", + "NameExtraUnknown": "إضافي" } 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/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 5d0ef65842..c5b1213096 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -106,5 +106,17 @@ "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", "CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка", - "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён." + "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.", + "LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}", + "NameExtraDeletedScene": "Выдаленая сцэна", + "NameExtraInterview": "Інтэрв'ю", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Сцэна", + "NameExtraTrailer": "Трэйлер", + "NameExtraBehindTheScenes": "За кулісамі", + "NameExtraClip": "Кліп", + "NameExtraFeaturette": "Кароткаметражка", + "NameExtraSample": "Прыклад", + "NameExtraShort": "Кароткаметражка", + "NameExtraThemeSong": "Тэматычная песня" } diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 0710a39708..3d49675c63 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -106,5 +106,20 @@ "TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.", "TaskExtractMediaSegments": "Сканиране за сегменти", "CleanupUserDataTask": "Задача за почистване на потребителски данни", - "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни." + "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.", + "LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}", + "NameExtraBehindTheScenes": "Зад кулисите", + "NameExtraScene": "Сцена", + "NameExtraShort": "Откъс", + "NameExtraThemeVideo": "Тематично видео", + "NameExtraTrailer": "Трейлър", + "NameExtraUnknown": "Екстра", + "NameExtraClip": "Клип", + "NameExtraDeletedScene": "Изтрита Сцена", + "NameExtraFeaturette": "Кратък филм", + "NameExtraInterview": "Интервю", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Пример", + "NameExtraThemeSong": "Тема-песен", + "Original": "Оригинал" } diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json index aa7fe4eb24..5686807d9a 100644 --- a/Emby.Server.Implementations/Localization/Core/bs.json +++ b/Emby.Server.Implementations/Localization/Core/bs.json @@ -106,5 +106,17 @@ "TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay", "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.", "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", - "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana." + "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana.", + "NameExtraBehindTheScenes": "Iza kulisa", + "NameExtraClip": "Isječak", + "NameExtraDeletedScene": "Izbrišana scena", + "NameExtraFeaturette": "Kratki prilog", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Uzorak", + "NameExtraScene": "Scena", + "NameExtraShort": "Kratko", + "NameExtraThemeSong": "Tema", + "NameExtraThemeVideo": "Tematski video", + "NameExtraTrailer": "Najava" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 6c81726ee6..12076d6c15 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.", "CleanupUserDataTask": "Tasca de neteja de dades d'usuari", "Original": "Original", - "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}" + "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}", + "NameExtraBehindTheScenes": "Rere les càmeres", + "NameExtraClip": "Tall", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Migmetratge", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mostra", + "NameExtraScene": "Escena", + "NameExtraShort": "Curt", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temàtic", + "NameExtraTrailer": "Tràiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 28f0e2df97..033002d2b2 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.", "CleanupUserDataTask": "Pročistit uživatelská data", "Original": "Originál", - "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}" + "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}", + "NameExtraBehindTheScenes": "Zákulisí", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Vymazaná scéna", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Rozhovor", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ukázka", + "NameExtraScene": "Scéna", + "NameExtraShort": "Krátké", + "NameExtraThemeSong": "Úvodní píseň", + "NameExtraThemeVideo": "Úvodní video", + "NameExtraTrailer": "Upoutávka", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 697d9c090f..6e5532ead9 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", @@ -51,7 +51,7 @@ "Shows": "Serier", "StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.", "SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}", - "TvShows": "TV-serier", + "TvShows": "Serier", "UserCreatedWithName": "Bruger {0} er blevet oprettet", "UserDeletedWithName": "Brugeren {0} er nu slettet", "UserDownloadingItemWithValues": "{0} henter {1}", @@ -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", - "TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.", - "TaskCleanTranscode": "Tøm omkodningsmappen", - "TaskRefreshPeople": "Opdater personer", + "TaskRefreshChannels": "Opdatér kanaler", + "TaskCleanTranscodeDescription": "Fjerner transkodningsfiler, som er mere end 1 dag gamle.", + "TaskCleanTranscode": "Tøm transkodningsmappen", + "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", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Brugerdata oprydningsopgave", "CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.", "LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Bag scenerne", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Slettet scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Smagsprøve", + "NameExtraScene": "Scene", + "NameExtraShort": "Kort", + "NameExtraThemeSong": "Temasang", + "NameExtraThemeVideo": "Temavideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Ekstra" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 8ac5fdf6fc..ce5a4c0a6c 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -1,6 +1,6 @@ { "AppDeviceValues": "App: {0}, Gerät: {1}", - "Artists": "Interpreten", + "Artists": "Künstler", "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", "ChapterNameValue": "Kapitel {0}", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten", "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.", "Original": "Original", - "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}" + "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraDeletedScene": "Entfernte Szene", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ausschnitt", + "NameExtraScene": "Szene", + "NameExtraShort": "Kurzfilm", + "NameExtraThemeSong": "Titellied", + "NameExtraThemeVideo": "Titelvideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraClip": "Clip", + "NameExtraFeaturette": "Hinter den Kulissen" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index d163a929a4..ee03c471ea 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -107,5 +107,19 @@ "TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.", "CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.", "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη", - "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}" + "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}", + "Original": "Πρωτότυπο", + "NameExtraBehindTheScenes": "Πίσω από τις Σκηνές", + "NameExtraDeletedScene": "Διεγραμμένη Σκηνή", + "NameExtraFeaturette": "Πρόσθετα βίντεο", + "NameExtraInterview": "Συνέντευξη", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Δείγμα", + "NameExtraScene": "Σκηνή", + "NameExtraShort": "Βίντεο μικρού μήκους", + "NameExtraThemeSong": "Θεματικό Τραγούδι", + "NameExtraThemeVideo": "Θεματικό Βίντεο", + "NameExtraTrailer": "τρέιλερ ταινίας", + "NameExtraUnknown": "Πρόσθετα", + "NameExtraClip": "Απόσπασμα" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index be152b515f..a053fc2da9 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -24,8 +24,8 @@ "Music": "Music", "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", + "NameSeasonNumber": "Series {0}", + "NameSeasonUnknown": "Series Unknown", "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", "NotificationOptionApplicationUpdateAvailable": "Application update available", "NotificationOptionApplicationUpdateInstalled": "Application update installed", @@ -106,5 +106,20 @@ "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", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 856941c61a..578c85da9d 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -28,6 +28,19 @@ "Movies": "Movies", "Music": "Music", "MusicVideos": "Music Videos", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json index 133a2755a8..e59f089459 100644 --- a/Emby.Server.Implementations/Localization/Core/eo.json +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -97,5 +97,9 @@ "TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.", "TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)", "TaskAudioNormalization": "Normaligo Sonnivela", - "HearingImpaired": "Surda" + "HearingImpaired": "Surda", + "NameExtraDeletedScene": "Forigita sceno", + "NameExtraScene": "Sceno", + "NameExtraThemeSong": "Tema Kanto", + "Original": "Originala" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 28366a41b7..a30abb9d4e 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -106,5 +106,20 @@ "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", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index ac489b9e77..f4cf45cb12 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -106,5 +106,20 @@ "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.", "CleanupUserDataTask": "Tarea de limpieza de los datos del usuario", - "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días." + "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días.", + "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 563dce8fe6..9e82e0601b 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -108,5 +108,18 @@ "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}", + "NameExtraBehindTheScenes": "Detrás de Cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Reportaje especial", + "NameExtraInterview": "Entrevista", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Tema principal", + "NameExtraThemeVideo": "Vídeo del tema principal", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 1f13451060..274c60c7bc 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -106,5 +106,20 @@ "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", + "NameExtraUnknown": "Extra", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler" } diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index e6bf1f25b5..a7afcf5b77 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Puhasta kasutajaandmed", "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.", "LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus", - "Original": "Algne" + "Original": "Algne", + "NameExtraBehindTheScenes": "Kulisside taga", + "NameExtraDeletedScene": "Väljajäetud stseen", + "NameExtraFeaturette": "Lisalõik", + "NameExtraInterview": "Intervjuu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Näidis", + "NameExtraScene": "Stseen", + "NameExtraShort": "Lühifilm", + "NameExtraThemeSong": "Tunnusmeloodia", + "NameExtraThemeVideo": "Tunnusvideo", + "NameExtraTrailer": "Treiler", + "NameExtraUnknown": "Lisamaterjal", + "NameExtraClip": "Videoklipp" } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index 71c351adcd..df6855d6e3 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.", "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina", "LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik", - "Original": "Jatorrizkoa" + "Original": "Jatorrizkoa", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Lagina", + "NameExtraScene": "Eszena", + "NameExtraShort": "Laburra", + "NameExtraThemeSong": "Gai-abestia", + "NameExtraThemeVideo": "Gai-bideoa", + "NameExtraBehindTheScenes": "Eszenen atzean", + "NameExtraClip": "Klipa", + "NameExtraDeletedScene": "Ezabatutako eszena", + "NameExtraFeaturette": "Erreportajea", + "NameExtraInterview": "Elkarrizketa", + "NameExtraTrailer": "Trailerra", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index d080eb0236..9a453120dd 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä", "CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään.", "LyricDownloadFailureFromForItem": "Sanoitusten lataus kohteesta {0} kappaleelle {1} epäonnistui", - "Original": "Alkuperäinen" + "Original": "Alkuperäinen", + "NameExtraBehindTheScenes": "Kulissien Takana", + "NameExtraClip": "Klippi", + "NameExtraDeletedScene": "Poistettu Kohtaus", + "NameExtraFeaturette": "Lyhytelokuva", + "NameExtraInterview": "Haastattelu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Näyte", + "NameExtraScene": "Kohtaus", + "NameExtraShort": "Lyhytfilmi", + "NameExtraThemeSong": "Tunnusmusiikki", + "NameExtraThemeVideo": "Tunnusvideo", + "NameExtraTrailer": "Traileri" } diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 4fb9f4329c..023ed6ec5e 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -1,16 +1,125 @@ { - "Artists": "Listafólk", - "Collections": "Søvn", - "Default": "Sjálvgildi", + "Artists": "Tónlistafólk", + "Collections": "Samlingar", + "Default": "Forsett", "External": "Ytri", "Genres": "Greinar", "AppDeviceValues": "App: {0}, Eind: {1}", "Books": "Bøkur", "ChapterNameValue": "Kapittul {0}", - "Favorites": "Yndis", - "Folders": "Mappur", + "Favorites": "Yndislista", + "Folders": "Skjáttur", "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} innlagt", + "PluginUninstalledWithName": "{0} strikað", + "PluginUpdatedWithName": "{0} 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", + "Movies": "Filmar", + "MixedContent": "Blandað innihald", + "Music": "Tónleikur", + "UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}", + "HeaderContinueWatching": "Hald áfram at hyggja", + "MusicVideos": "Sjónbandaløg", + "TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.", + "TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.", + "TaskOptimizeDatabase": "Albøt dátugrunn", + "NameSeasonNumber": "Umfar {0}", + "NameSeasonUnknown": "Ókent umfar", + "ScheduledTaskFailedWithName": "{0} miseydnaðist", + "Undefined": "Óskilmarkað", + "TasksMaintenanceCategory": "Viðlíkahald", + "TaskCleanLogs": "Reinsa gerðalistaskjáttu", + "UserOnlineFromDevice": "{0} er íbundin frá {1}", + "HeaderNextUp": "Næst á skránni", + "NotificationOptionPluginError": "Brek í ískoytisforriti", + "NotificationOptionInstallationFailed": "Innleggingarbrek", + "NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan", + "TasksApplicationCategory": "Nýtsluskipan", + "NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk", + "NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd", + "UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}", + "HomeVideos": "Heimaupptøkur", + "StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.", + "UserOfflineFromDevice": "{0} breyt av frá {1}", + "UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt", + "TasksChannelsCategory": "Alnetsrásir", + "TaskCleanActivityLog": "Reinsa virksemisskrá", + "TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.", + "TaskCleanCache": "Reinsa kovaskjáttu", + "TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.", + "TaskCleanTranscode": "Reinsa umkotuskjáttu", + "TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir", + "TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir", + "CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.", + "CleanupUserDataTask": "Reinsa brúkaradátur", + "TaskRefreshPeople": "Dagfør persónsupplýsingar", + "TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.", + "TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.", + "TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.", + "NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu", + "TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.", + "TaskKeyframeExtractor": "Lyklamyndaúttøka", + "TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.", + "TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.", + "TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.", + "TaskRefreshChapterImages": "Kapitlamyndaúttøkur", + "NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað", + "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað", + "NotificationOptionAudioPlayback": "Ljóðspæl byrjað", + "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað", + "TaskExtractMediaSegments": "Leita eftir margmiðlabrotum", + "TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.", + "NotificationOptionCameraImageUploaded": "Ljósmynd uppsend", + "NameExtraShort": "Stuttfilmur", + "NameExtraThemeSong": "Eyðkennislag", + "NameExtraTrailer": "Brellbiti", + "NameExtraInterview": "Samrøða", + "NameExtraBehindTheScenes": "Aftanfyri leiktjøldini", + "NameExtraClip": "Klipp", + "NameExtraNumbered": "{0} {1}", + "NameExtraFeaturette": "Stuttur heimildarfilmur", + "TaskAudioNormalization": "Ljóðjavnan", + "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.", + "NameExtraSample": "Sýnislutur", + "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir", + "TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.", + "TaskMoveTrickplayImages": "Flyt Trickplay-myndir", + "TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.", + "NameExtraThemeVideo": "Eyðkenniskykmynd", + "NameExtraDeletedScene": "Úrtikin mynd", + "NameExtraScene": "Mynd", + "NameExtraUnknown": "Eykatilfar", + "Original": "Upprunalig(t/ur)" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index e05cce47b0..393af79ab8 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", "LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Dans Les Coulisses", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scène supprimée", + "NameExtraFeaturette": "Court-métrage", + "NameExtraInterview": "Entrevue", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Échantillon", + "NameExtraScene": "Scène", + "NameExtraShort": "Court-métrage", + "NameExtraThemeSong": "Chanson thème", + "NameExtraThemeVideo": "Générique", + "NameExtraTrailer": "Bande-annonce", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index ceba1dcb41..858fd9eff6 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", "LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Dans Les Coulisses", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scène supprimée", + "NameExtraFeaturette": "Court-métrage", + "NameExtraInterview": "Entrevue", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Échantillon", + "NameExtraScene": "Scène", + "NameExtraShort": "Court-métrage", + "NameExtraThemeSong": "Thème musical", + "NameExtraThemeVideo": "Générique", + "NameExtraTrailer": "Bande-annonce", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 1ee606cc64..30e11d15f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora", "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.", "Original": "Bunaidh", - "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}" + "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}", + "NameExtraBehindTheScenes": "Taobh thiar de na Radhairc", + "NameExtraClip": "Gearrthóg", + "NameExtraDeletedScene": "Radharc Scriosta", + "NameExtraFeaturette": "Mionghné", + "NameExtraInterview": "Agallamh", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sampla", + "NameExtraScene": "Radharc", + "NameExtraShort": "Gearr", + "NameExtraThemeSong": "Amhrán Téama", + "NameExtraThemeVideo": "Físeán Téama", + "NameExtraTrailer": "Leantóir" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index d3740130ee..9eb9786b8a 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -106,5 +106,20 @@ "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", + "LyricDownloadFailureFromForItem": "Non se puideron descargar as letras desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás das Cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena Eliminada", + "NameExtraFeaturette": "Reportaxe especial", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mostra", + "NameExtraScene": "Escena", + "NameExtraShort": "Curtametraxe", + "NameExtraThemeSong": "Canción principal", + "NameExtraThemeVideo": "Vídeo da canción principal", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index af34bf092e..0ca7c6aa08 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -106,5 +106,14 @@ "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.", "TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.", "CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.", - "CleanupUserDataTask": "משימת ניקוי מידע משתמש" + "CleanupUserDataTask": "משימת ניקוי מידע משתמש", + "LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה", + "Original": "מקור", + "NameExtraBehindTheScenes": "מאחורי הקלעים", + "NameExtraClip": "קליפ", + "NameExtraDeletedScene": "סצנה שנמחקה", + "NameExtraFeaturette": "סרט קצר", + "NameExtraInterview": "ריאיון", + "NameExtraSample": "דגימה", + "NameExtraScene": "סצנה" } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index e98a5fbac1..f4b1f86d1d 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -105,5 +105,21 @@ "TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।", "TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें", "TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।", - "CleanupUserDataTask": "यूज़र डेटा सफाई कार्य" + "CleanupUserDataTask": "यूज़र डेटा सफाई कार्य", + "Original": "असली", + "LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा", + "NameExtraBehindTheScenes": "परदे के पीछे", + "NameExtraClip": "क्लिप", + "NameExtraDeletedScene": "हटाया गया दृश्य", + "NameExtraFeaturette": "फीचरेट", + "NameExtraInterview": "साक्षात्कार", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "नमूना", + "NameExtraScene": "दृश्य", + "NameExtraShort": "शॉर्ट", + "NameExtraThemeSong": "थीम सॉन्ग", + "NameExtraThemeVideo": "थीम वीडियो", + "NameExtraTrailer": "ट्रेलर", + "NameExtraUnknown": "अतिरिक्त", + "CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 8794339fb1..2d38c173f0 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -107,5 +107,18 @@ "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.", "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo", + "NameExtraBehindTheScenes": "Iza kulisa", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Obrisana Scena", + "NameExtraFeaturette": "Promotivni video", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Uzorak", + "NameExtraScene": "Scena", + "NameExtraShort": "Kratki film", + "NameExtraThemeSong": "Glavna Pjesma", + "NameExtraThemeVideo": "Tema videa", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 1995d7a4cf..4a327e7b02 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.", "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat", "Original": "Eredeti", - "LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen" + "LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen", + "NameExtraBehindTheScenes": "Színfalak mögött", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Törölt jelenet", + "NameExtraFeaturette": "Kísérő film", + "NameExtraInterview": "Interjú", + "NameExtraSample": "Minta", + "NameExtraScene": "Jelenet", + "NameExtraShort": "Rövidfilm", + "NameExtraThemeSong": "Főcímdal", + "NameExtraThemeVideo": "Főcímvideó", + "NameExtraTrailer": "Előzetes", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json index b79b540bf4..80be71ed70 100644 --- a/Emby.Server.Implementations/Localization/Core/hy.json +++ b/Emby.Server.Implementations/Localization/Core/hy.json @@ -24,5 +24,10 @@ "TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը", "AppDeviceValues": "Հավելված` {0}, Սարք `{1}", "ChapterNameValue": "Գլուխ {0}", - "Collections": "Հավաքածուներ" + "Collections": "Հավաքածուներ", + "Artists": "Երաժիշտներ", + "Default": "Լռելյայն", + "Favorites": "Ընտրյալ", + "Forced": "Ստիպուած", + "Genres": "Ոճ" } 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..a25857e510 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -62,7 +62,7 @@ "UserDownloadingItemWithValues": "{0} hleður niður {1}", "SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}", "Shows": "Þættir", - "TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.", + "TaskRefreshChannelsDescription": "Endurhleður upplýsingum netrása.", "TaskRefreshChannels": "Endurhlaða Rásir", "TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.", "TaskCleanTranscode": "Hreinsa Umkóðunarmöppu", @@ -80,7 +80,7 @@ "TasksMaintenanceCategory": "Viðhald", "Default": "Sjálfgefið", "TaskCleanActivityLog": "Hreinsa athafnaskrá", - "TaskRefreshPeople": "Endurnýja fólk", + "TaskRefreshPeople": "Endurnýja upplýsingar um fólk", "TaskDownloadMissingSubtitles": "Sækja texta sem vantar", "TaskOptimizeDatabase": "Fínstilla gagnagrunn", "Undefined": "Óskilgreint", @@ -95,13 +95,31 @@ "TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.", "Forced": "Þvingað", "External": "Útvær", - "TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.", - "TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir", + "TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir (Trickplay) fyrir myndbönd í virkum söfnum.", + "TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir (Trickplay)", "TaskAudioNormalization": "Hljóðstöðlun", "TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.", "TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög", "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.", + "TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir", + "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins.", + "NameExtraBehindTheScenes": "Bak við tjöldin", + "NameExtraClip": "Brot", + "NameExtraDeletedScene": "Eydd atriði", + "NameExtraFeaturette": "Stutt heimildarmynd", + "NameExtraInterview": "Viðtal", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sýnishorn", + "NameExtraScene": "Sena", + "NameExtraShort": "Stuttmynd", + "NameExtraThemeSong": "Þema lag", + "NameExtraThemeVideo": "Þema myndband", + "NameExtraTrailer": "Stikla", + "NameExtraUnknown": "Aukaefni" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index f13944e6be..41097ed92e 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Task di pulizia dei dati utente", "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.", "Original": "Originale", - "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}" + "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}", + "NameExtraBehindTheScenes": "Dietro le scene", + "NameExtraClip": "Filmato", + "NameExtraDeletedScene": "Scena eliminata", + "NameExtraInterview": "Intervista", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Scena", + "NameExtraSample": "Campione", + "NameExtraShort": "Corto", + "NameExtraThemeSong": "Sigla musicale", + "NameExtraTrailer": "Trailer", + "NameExtraFeaturette": "Caratteristica", + "NameExtraThemeVideo": "Video tematico", + "NameExtraUnknown": "Extra" } 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/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..1f16a90842 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -106,5 +106,10 @@ "TaskDownloadMissingLyrics": "누락된 가사 다운로드", "TaskDownloadMissingLyricsDescription": "가사 다운로드", "CleanupUserDataTask": "사용자 데이터 정리 작업", - "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다." + "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", + "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다", + "Original": "원본", + "NameExtraClip": "클립", + "NameExtraDeletedScene": "삭제된 장면", + "NameExtraInterview": "인터뷰" } diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json index e94709b083..21d31c5fbf 100644 --- a/Emby.Server.Implementations/Localization/Core/lb.json +++ b/Emby.Server.Implementations/Localization/Core/lb.json @@ -104,5 +104,21 @@ "TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.", "TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.", "TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.", - "TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren." + "TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren.", + "LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}", + "Original": "Original", + "CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten", + "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.", + "NameExtraBehindTheScenes": "Hannert de Kulissen", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Geläschte Scène", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Beispill", + "NameExtraScene": "Scène", + "NameExtraShort": "Kuerzfilm", + "NameExtraThemeSong": "Theme-Lidd", + "NameExtraThemeVideo": "Theme-Video", + "NameExtraTrailer": "Bande-Annonce" } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index ed26004a43..db4710ddca 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -3,15 +3,15 @@ "Artists": "Atlikėjai", "AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota", "Books": "Knygos", - "ChapterNameValue": "Scena{0}", - "Collections": "Rinkiniai", + "ChapterNameValue": "Skyrius{0}", + "Collections": "Kolekcijos", "FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti", "Favorites": "Mėgstami", - "Folders": "Katalogai", + "Folders": "Aplankai", "Genres": "Žanrai", "HeaderContinueWatching": "Žiūrėti toliau", - "HeaderFavoriteEpisodes": "Mėgstamiausios serijos", - "HeaderFavoriteShows": "Mėgstamiausios TV Laidos", + "HeaderFavoriteEpisodes": "Mėgstami Epizodai", + "HeaderFavoriteShows": "Mėgstamos TV Laidos", "HeaderLiveTV": "Tiesioginė TV", "HeaderNextUp": "Toliau", "HomeVideos": "Namų vaizdo įrašai", @@ -26,14 +26,14 @@ "NameInstallFailed": "{0} diegimo klaida", "NameSeasonNumber": "Sezonas {0}", "NameSeasonUnknown": "Sezonas neatpažintas", - "NewVersionIsAvailable": "Nauja \"Jellyfin Server\" versija yra prieinama atsisiuntimui.", + "NewVersionIsAvailable": "Nauja Jellyfin Server versija yra prieinama atsisiuntimui.", "NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai", "NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti", "NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas", "NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas", - "NotificationOptionCameraImageUploaded": "Kameros vaizdai įkelti", + "NotificationOptionCameraImageUploaded": "Kameros atvaizdai įkelti", "NotificationOptionInstallationFailed": "Diegimo klaida", - "NotificationOptionNewLibraryContent": "Naujas turinys įkeltas", + "NotificationOptionNewLibraryContent": "Pridėtas naujas turinys", "NotificationOptionPluginError": "Įskiepio klaida", "NotificationOptionPluginInstalled": "Įskiepis įdiegtas", "NotificationOptionPluginUninstalled": "Įskiepis išdiegtas", @@ -51,7 +51,7 @@ "Shows": "Laidos", "StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.", "SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}", - "TvShows": "TV laidos", + "TvShows": "TV Laidos", "UserCreatedWithName": "Buvo sukurtas {0} naudotojas", "UserDeletedWithName": "Naudotojas {0} ištrintas", "UserDownloadingItemWithValues": "{0} siunčiasi {1}", @@ -59,14 +59,14 @@ "UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}", "UserOnlineFromDevice": "{0} prisijungęs iš {1}", "UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}", - "UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}", - "UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}", + "UserStartedPlayingItemWithValues": "{0} atkuriama {1} į {2}", + "UserStoppedPlayingItemWithValues": "{0} baigė atkūrimą {1} į {2}", "VersionNumber": "Versija {0}", "TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.", "TaskUpdatePlugins": "Atnaujinti įskieius", "TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.", - "TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.", - "TaskCleanTranscode": "Išvalyti perkodavimo katalogą", + "TaskCleanTranscodeDescription": "Ištrina dienos senumo transkodavimo failus.", + "TaskCleanTranscode": "Išvalyti transkodavimo katalogą", "TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.", "TaskRefreshLibrary": "Skenuoti medijos biblioteką", "TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus", @@ -76,8 +76,8 @@ "TaskRefreshPeople": "Atnaujinti žmones", "TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.", "TaskCleanLogs": "Išvalyti žurnalą", - "TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.", - "TaskRefreshChapterImages": "Ištraukti skyrių vaizdus", + "TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašams, kuriuose yra skyriai.", + "TaskRefreshChapterImages": "Ištraukti skyrių atvaizdus", "TaskCleanCache": "Išvalyti talpyklą", "TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.", "TasksChannelsCategory": "Internetiniai kanalai", @@ -87,7 +87,7 @@ "TaskCleanActivityLog": "Išvalyti veiklos žurnalą", "Undefined": "Neapibrėžtas", "Forced": "Priverstinis", - "Default": "Numatytas", + "Default": "Numatytasis", "TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.", "TaskOptimizeDatabase": "Optimizuoti duomenų bazę", "TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.", @@ -96,15 +96,30 @@ "External": "Išorinis", "HearingImpaired": "Su klausos sutrikimais", "TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus", - "TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.", + "TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įjungtose bibliotekose, Trickplay peržiūras.", "TaskAudioNormalization": "Garso normalizavimas", "TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.", "TaskExtractMediaSegments": "Medijos segmentų nuskaitymas", - "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus", + "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus", "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.", - "TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą", - "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.", - "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius", + "TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą", + "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.", + "TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus", "CleanupUserDataTask": "Naudotojo duomenų valymo užduotis", - "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)." + "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).", + "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}", + "NameExtraBehindTheScenes": "Užkulisiuose", + "NameExtraClip": "Klipas", + "NameExtraDeletedScene": "Ištrinta scena", + "NameExtraInterview": "Interviu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Pavyzdys", + "NameExtraScene": "Scena", + "NameExtraThemeSong": "Teminė daina", + "NameExtraThemeVideo": "Teminis vaizdo įrašas", + "NameExtraTrailer": "Anonsas", + "NameExtraUnknown": "Papildomas", + "Original": "Originalus", + "NameExtraFeaturette": "Trumpametražis filmas", + "NameExtraShort": "Trumpas filmukas" } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 4a1b248e76..52f1eecbe4 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -107,5 +107,18 @@ "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām", "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.", - "Original": "Oriģināls" + "Original": "Oriģināls", + "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}", + "NameExtraBehindTheScenes": "Aiz kadra", + "NameExtraClip": "Klips", + "NameExtraDeletedScene": "Izdzēsta aina", + "NameExtraFeaturette": "Īsfilma", + "NameExtraInterview": "Intervija", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Paraugs", + "NameExtraScene": "Aina", + "NameExtraShort": "Īsfilma", + "NameExtraThemeSong": "Motīvu dziesma", + "NameExtraThemeVideo": "Tēmas video", + "NameExtraTrailer": "Treileris" } diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 9fc61fadd5..bb4469ad9c 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -106,5 +106,11 @@ "TaskAudioNormalization": "Normalisasi Audio", "TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.", "CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (keadaan tontonan, status kegemaran, dan sebagainya) daripada media yang tidak lagi wujud sekurang-kurangnya selama 90 hari.", - "CleanupUserDataTask": "Tugas pembersihan data pengguna" + "CleanupUserDataTask": "Tugas pembersihan data pengguna", + "LyricDownloadFailureFromForItem": "Lirik gagal dimuat turun dari {0} untuk {1}", + "NameExtraBehindTheScenes": "Di Sebalik Takbir", + "NameExtraClip": "Klip", + "NameExtraInterview": "Temu bual", + "NameExtraThemeSong": "Lagu Tema", + "NameExtraThemeVideo": "Video Tema" } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 752b74ec1c..735bc2c793 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -106,5 +106,15 @@ "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.", "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.", "CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.", - "CleanupUserDataTask": "Oppgave for opprydding av brukerdata" + "CleanupUserDataTask": "Oppgave for opprydding av brukerdata", + "NameExtraBehindTheScenes": "Bak kulissene", + "NameExtraDeletedScene": "Slettet scene", + "NameExtraFeaturette": "Presentasjonsfilm", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Prøve", + "NameExtraScene": "Scene", + "NameExtraThemeSong": "Tema-låt", + "NameExtraThemeVideo": "Tema-video", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 9aea3adc22..28ac66e70d 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Opruimtaak gebruikersdata", "Genres": "Genres", "Original": "Oorspronkelijk", - "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt" + "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt", + "NameExtraBehindTheScenes": "Achter de schermen", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Geschrapte scène", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Vraaggesprek", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Voorbeeldfragment", + "NameExtraScene": "Scène", + "NameExtraShort": "Korte film", + "NameExtraThemeSong": "Themamuziek", + "NameExtraThemeVideo": "Themavideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra inhoud" } diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json index cad5640763..4f573b3c58 100644 --- a/Emby.Server.Implementations/Localization/Core/oc.json +++ b/Emby.Server.Implementations/Localization/Core/oc.json @@ -1,3 +1,27 @@ { - "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}" + "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}", + "Books": "Libres", + "Artists": "Artistas", + "Collections": "Collecciones", + "ChapterNameValue": "Capitol {0}", + "External": "Extèrn", + "Folders": "Dorsièrs", + "Favorites": "Favorits", + "HeaderContinueWatching": "Contunhar de regardar", + "HeaderFavoriteEpisodes": "Episòdis Favorits", + "AuthenticationSucceededWithUserName": "{0} autentificat amb succès", + "HeaderFavoriteShows": "Serias Favoritas", + "HeaderLiveTV": "TV en dirècte", + "HeaderNextUp": "Seguent", + "HearingImpaired": "Amb de deficiéncias auditivas", + "Movies": "Filmes", + "Music": "Musica", + "Latest": "Darrièr", + "Forced": "Forçat", + "Default": "Defaut", + "Genres": "Genres", + "HomeVideos": "Vidèos d'Acuèlh", + "Inherit": "Eiretar", + "LabelIpAddressValue": "Adreça IP: {0}", + "LabelRunningTimeValue": "Temps d'execucion : {0}" } diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index e609ad52c8..39bbe8bb55 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -106,5 +106,6 @@ "TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।", "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।", "CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।", - "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ" + "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ", + "LyricDownloadFailureFromForItem": "{1} ਲਈ {0} ਤੋਂ ਬੋਲ ਡਾਊਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ।" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index c4657bdd6e..71909fd73a 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.", "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika", "Original": "Oryginalny", - "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}" + "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}", + "NameExtraBehindTheScenes": "Za kulisami", + "NameExtraClip": "Urywek", + "NameExtraDeletedScene": "Usunięta scena", + "NameExtraFeaturette": "Film średniometrażowy", + "NameExtraInterview": "Wywiad", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Fragment", + "NameExtraScene": "Scena", + "NameExtraShort": "Film krótkometrażowy", + "NameExtraThemeSong": "Czołówka", + "NameExtraThemeVideo": "Wideo wprowadzające", + "NameExtraTrailer": "Zwiastun", + "NameExtraUnknown": "Dodatek" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 1db500adf3..997d534fea 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Tarefa de limpeza de dados do usuário", "CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias.", "LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Nos Bastidores", + "NameExtraClip": "Clipe", + "NameExtraDeletedScene": "cena Extra", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Trecho", + "NameExtraScene": "Cena", + "NameExtraShort": "Curta-metragem", + "NameExtraThemeSong": "Música Tema", + "NameExtraThemeVideo": "Vídeo de Abertura", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraFeaturette": "Nos Bastidores", + "NameExtraInterview": "Entrevista" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index dd482d1e9b..e71475afd8 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -5,107 +5,121 @@ "Books": "Livros", "ChapterNameValue": "Capítulo {0}", "Collections": "Coleções", - "FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}", + "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}", "Favorites": "Favoritos", "Folders": "Pastas", "Genres": "Géneros", "HeaderContinueWatching": "Continuar a ver", - "HeaderFavoriteEpisodes": "Episódios Favoritos", - "HeaderFavoriteShows": "Séries Favoritas", - "HeaderLiveTV": "TV em Direto", - "HeaderNextUp": "A Seguir", - "HomeVideos": "Vídeos Caseiros", + "HeaderFavoriteEpisodes": "Episódios favoritos", + "HeaderFavoriteShows": "Séries favoritas", + "HeaderLiveTV": "TV em direto", + "HeaderNextUp": "A seguir", + "HomeVideos": "Vídeos caseiros", "Inherit": "Herdar", "LabelIpAddressValue": "Endereço IP: {0}", "LabelRunningTimeValue": "Duração: {0}", - "Latest": "Mais Recente", - "MixedContent": "Conteúdo Misto", + "Latest": "Mais recente", + "MixedContent": "Conteúdo misto", "Movies": "Filmes", "Music": "Música", "MusicVideos": "Videoclipes", - "NameInstallFailed": "{0} falha na instalação", + "NameInstallFailed": "Falha na instalação de {0}", "NameSeasonNumber": "Temporada {0}", - "NameSeasonUnknown": "Temporada Desconhecida", + "NameSeasonUnknown": "Temporada desconhecida", "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.", "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível", "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada", - "NotificationOptionAudioPlayback": "Reprodução Iniciada", - "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", + "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada", + "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida", "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada", "NotificationOptionInstallationFailed": "Falha na instalação", "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", - "NotificationOptionPluginError": "Falha na extensão", - "NotificationOptionPluginInstalled": "Extensão instalada", - "NotificationOptionPluginUninstalled": "Extensão desinstalada", - "NotificationOptionPluginUpdateInstalled": "Extensão atualizada", + "NotificationOptionPluginError": "Falha no plugin", + "NotificationOptionPluginInstalled": "Plugin instalado", + "NotificationOptionPluginUninstalled": "Plugin desinstalado", + "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada", "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor", "NotificationOptionTaskFailed": "Falha em tarefa agendada", "NotificationOptionUserLockedOut": "Utilizador bloqueado", - "NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada", - "NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada", + "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", + "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida", "Photos": "Fotografias", "PluginInstalledWithName": "{0} foi instalado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginUpdatedWithName": "{0} foi atualizado", "ScheduledTaskFailedWithName": "{0} falhou", "Shows": "Séries", - "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.", - "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}", - "TvShows": "Séries", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.", + "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}", + "TvShows": "Séries de TV", "UserCreatedWithName": "Utilizador {0} criado", - "UserDeletedWithName": "Utilizador {0} apagado", + "UserDeletedWithName": "Utilizador {0} eliminado", "UserDownloadingItemWithValues": "{0} está a transferir {1}", "UserLockedOutWithName": "Utilizador {0} bloqueado", - "UserOfflineFromDevice": "{0} desligou-se a partir de {1}", - "UserOnlineFromDevice": "{0} ligou-se a partir de {1}", + "UserOfflineFromDevice": "{0} desligou-se de {1}", + "UserOnlineFromDevice": "{0} está online em {1}", "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}", "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", "VersionNumber": "Versão {0}", - "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.", + "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.", "TaskDownloadMissingSubtitles": "Transferir legendas em falta", - "TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.", - "TaskRefreshChannels": "Atualizar Canais", - "TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.", - "TaskCleanTranscode": "Limpar a Diretoria de Transcode", - "TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.", - "TaskUpdatePlugins": "Atualizar Plugins", - "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.", - "TaskRefreshPeople": "Atualizar Pessoas", - "TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.", - "TaskCleanLogs": "Limpar a Diretoria de Logs", - "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.", - "TaskRefreshLibrary": "Analisar mediateca", - "TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.", - "TaskRefreshChapterImages": "Extrair Imagens dos Capítulos", - "TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.", - "TaskCleanCache": "Limpar Cache", + "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.", + "TaskRefreshChannels": "Atualizar canais", + "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.", + "TaskCleanTranscode": "Limpar pasta de transcodificação", + "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.", + "TaskUpdatePlugins": "Atualizar plugins", + "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.", + "TaskRefreshPeople": "Atualizar pessoas", + "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.", + "TaskCleanLogs": "Limpar pasta de registos", + "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.", + "TaskRefreshLibrary": "Analisar biblioteca multimédia", + "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.", + "TaskRefreshChapterImages": "Extrair imagens dos capítulos", + "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.", + "TaskCleanCache": "Limpar pasta de cache", "TasksChannelsCategory": "Canais da Internet", "TasksApplicationCategory": "Aplicação", - "TasksLibraryCategory": "Mediateca", + "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manutenção", - "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.", + "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.", "TaskCleanActivityLog": "Limpar registo de atividade", "Undefined": "Indefinido", "Forced": "Forçado", "Default": "Predefinição", - "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", + "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.", "TaskOptimizeDatabase": "Otimizar base de dados", - "TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.", - "TaskKeyframeExtractor": "Extrator de Quadros-chave", + "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.", + "TaskKeyframeExtractor": "Extrator de fotogramas-chave", "External": "Externo", - "HearingImpaired": "Surdo", + "HearingImpaired": "Deficiência auditiva", "TaskRefreshTrickplayImages": "Gerar imagens de trickplay", "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.", "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.", "TaskAudioNormalization": "Normalização de áudio", - "TaskExtractMediaSegments": "Analisar segmentos de multimédia", - "TaskDownloadMissingLyrics": "Transferir letra em falta", - "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", - "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas", - "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", - "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.", + "TaskExtractMediaSegments": "Analisar segmentos multimédia", + "TaskDownloadMissingLyrics": "Transferir letras em falta", + "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay", + "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas", + "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.", + "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.", + "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.", "CleanupUserDataTask": "Limpeza de dados de utilizador", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}", + "NameExtraDeletedScene": "Cena eliminada", + "NameExtraInterview": "Entrevista", + "NameExtraUnknown": "Extra", + "NameExtraBehindTheScenes": "Bastidores", + "NameExtraSample": "Amostra", + "NameExtraScene": "Cena", + "NameExtraClip": "Excerto", + "NameExtraFeaturette": "Minidocumentário", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temático", + "NameExtraShort": "Curta-metragem", + "NameExtraNumbered": "{0} {1}", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index ce338acf34..8299a25992 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -1,112 +1,125 @@ { - "HeaderLiveTV": "TV Em Direto", + "HeaderLiveTV": "TV em direto", "Collections": "Coleções", "Books": "Livros", "Artists": "Artistas", - "HeaderNextUp": "A Seguir", - "HeaderFavoriteEpisodes": "Episódios Favoritos", - "HeaderFavoriteShows": "Séries Favoritas", + "HeaderNextUp": "A seguir", + "HeaderFavoriteEpisodes": "Episódios favoritos", + "HeaderFavoriteShows": "Séries favoritas", "HeaderContinueWatching": "Continuar a ver", "Genres": "Géneros", "Folders": "Pastas", "Favorites": "Favoritos", - "UserDownloadingItemWithValues": "{0} está sendo baixado {1}", + "UserDownloadingItemWithValues": "{0} está a transferir {1}", "VersionNumber": "Versão {0}", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", - "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", - "UserPasswordChangedWithName": "A senha do usuário {0} foi alterada", - "UserOnlineFromDevice": "{0} está online a partir de {1}", - "UserOfflineFromDevice": "{0} desconectou-se a partir de {1}", - "UserLockedOutWithName": "O usuário {0} foi bloqueado", - "UserDeletedWithName": "O usuário {0} foi removido", - "UserCreatedWithName": "O usuário {0} foi criado", - "TvShows": "Séries", - "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}", - "StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.", + "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", + "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}", + "UserOnlineFromDevice": "{0} está online em {1}", + "UserOfflineFromDevice": "{0} desligou-se de {1}", + "UserLockedOutWithName": "Utilizador {0} bloqueado", + "UserDeletedWithName": "Utilizador {0} eliminado", + "UserCreatedWithName": "Utilizador {0} criado", + "TvShows": "Séries de TV", + "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.", "ScheduledTaskFailedWithName": "{0} falhou", "PluginUpdatedWithName": "{0} foi atualizado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginInstalledWithName": "{0} foi instalado", - "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", + "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida", "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", - "NotificationOptionUserLockedOut": "Usuário bloqueado", - "NotificationOptionTaskFailed": "Falha na tarefa agendada", - "NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor", - "NotificationOptionPluginUpdateInstalled": "Plugin atualizado", + "NotificationOptionUserLockedOut": "Utilizador bloqueado", + "NotificationOptionTaskFailed": "Falha em tarefa agendada", + "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor", + "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada", "NotificationOptionPluginUninstalled": "Plugin desinstalado", "NotificationOptionPluginInstalled": "Plugin instalado", "NotificationOptionPluginError": "Falha no plugin", "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", - "NotificationOptionInstallationFailed": "Falha de instalação", - "NotificationOptionCameraImageUploaded": "Imagem de câmera enviada", - "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", - "NotificationOptionAudioPlayback": "Reprodução Iniciada", - "NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada", - "NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível", - "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.", - "NameSeasonUnknown": "Temporada Desconhecida", + "NotificationOptionInstallationFailed": "Falha na instalação", + "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada", + "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida", + "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada", + "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada", + "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível", + "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.", + "NameSeasonUnknown": "Temporada desconhecida", "NameSeasonNumber": "Temporada {0}", "NameInstallFailed": "Falha na instalação de {0}", "MusicVideos": "Videoclipes", "Music": "Música", - "MixedContent": "Conteúdo diverso", - "Latest": "Mais Recente", + "MixedContent": "Conteúdo misto", + "Latest": "Mais recente", "LabelRunningTimeValue": "Duração: {0}", - "LabelIpAddressValue": "Endereço de IP: {0}", + "LabelIpAddressValue": "Endereço IP: {0}", "Inherit": "Herdar", - "HomeVideos": "Vídeos Caseiros", + "HomeVideos": "Vídeos caseiros", "Shows": "Séries", "Photos": "Fotografias", "Movies": "Filmes", - "FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}", + "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}", "ChapterNameValue": "Capítulo {0}", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", "AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}", - "TaskCleanCache": "Limpar Diretório de Cache", + "TaskCleanCache": "Limpar pasta de cache", "TasksApplicationCategory": "Aplicação", - "TasksLibraryCategory": "Mediateca", + "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manutenção", - "TaskRefreshChannels": "Atualizar Canais", - "TaskUpdatePlugins": "Atualizar Plugins", - "TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.", - "TaskCleanLogs": "Limpar diretório de logs", - "TaskRefreshLibrary": "Analisar mediateca", + "TaskRefreshChannels": "Atualizar canais", + "TaskUpdatePlugins": "Atualizar plugins", + "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.", + "TaskCleanLogs": "Limpar pasta de registos", + "TaskRefreshLibrary": "Analisar biblioteca multimédia", "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.", - "TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.", - "TasksChannelsCategory": "Canais de Internet", - "TaskRefreshChapterImages": "Extrair Imagens do Capítulo", - "TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.", + "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.", + "TasksChannelsCategory": "Canais da Internet", + "TaskRefreshChapterImages": "Extrair imagens dos capítulos", + "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.", "TaskDownloadMissingSubtitles": "Transferir legendas em falta", - "TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.", - "TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.", - "TaskCleanTranscode": "Limpar o diretório de Transcode", - "TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.", - "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.", + "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.", + "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.", + "TaskCleanTranscode": "Limpar pasta de transcodificação", + "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.", + "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.", "TaskRefreshPeople": "Atualizar pessoas", - "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.", - "TaskCleanActivityLog": "Limpar registro de atividade", + "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.", + "TaskCleanActivityLog": "Limpar registo de atividade", "Undefined": "Indefinido", "Forced": "Forçado", "Default": "Predefinição", - "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.", + "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.", "TaskOptimizeDatabase": "Otimizar base de dados", - "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", + "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.", "External": "Externo", - "HearingImpaired": "Problemas auditivos", - "TaskKeyframeExtractor": "Extrator de quadro-chave", - "TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.", + "HearingImpaired": "Deficiência auditiva", + "TaskKeyframeExtractor": "Extrator de fotogramas-chave", + "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.", "TaskRefreshTrickplayImages": "Gerar imagens de trickplay", "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.", "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.", "TaskAudioNormalization": "Normalização de áudio", - "TaskDownloadMissingLyrics": "Transferir letra em falta", - "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas", - "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", - "TaskExtractMediaSegments": "Analisar segmentos de multimédia", - "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", - "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", - "CleanupUserDataTask": "Task de limpeza de dados do usuário", - "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.", + "TaskDownloadMissingLyrics": "Transferir letras em falta", + "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas", + "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.", + "TaskExtractMediaSegments": "Analisar segmentos multimédia", + "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.", + "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay", + "CleanupUserDataTask": "Limpeza de dados de utilizador", + "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.", "Original": "Original", - "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" + "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}", + "NameExtraBehindTheScenes": "Bastidores", + "NameExtraClip": "Excerto", + "NameExtraDeletedScene": "Cena eliminada", + "NameExtraFeaturette": "Minidocumentário", + "NameExtraInterview": "Entrevista", + "NameExtraSample": "Amostra", + "NameExtraShort": "Curta-metragem", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temático", + "NameExtraScene": "Cena", + "NameExtraUnknown": "Extra", + "NameExtraTrailer": "Trailer", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index ea83b88951..dccec8067d 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului", "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.", "LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "În culise", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scenă ștearsă", + "NameExtraFeaturette": "Material bonus", + "NameExtraInterview": "Interviu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Monstră", + "NameExtraScene": "Scenă", + "NameExtraShort": "Scurt", + "NameExtraThemeSong": "Audio de Fundal", + "NameExtraThemeVideo": "Video de Fundal", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 40d5e3985d..c13fd3ebdc 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Задача очистки пользовательских данных", "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.", "Original": "Оригинальный", - "LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}" + "LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}", + "NameExtraBehindTheScenes": "За кадром", + "NameExtraClip": "Отрывок", + "NameExtraDeletedScene": "Удалённая сцена", + "NameExtraFeaturette": "Короткометражка", + "NameExtraInterview": "Интервью", + "NameExtraSample": "Образец", + "NameExtraScene": "Сцена", + "NameExtraThemeSong": "Заглавная песня", + "NameExtraThemeVideo": "Заглавное видео", + "NameExtraTrailer": "Трейлер", + "NameExtraUnknown": "Дополнительный материал", + "NameExtraNumbered": "{0} {1}", + "NameExtraShort": "Короткометражка" } 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..a3ae6139ae 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -91,7 +91,7 @@ "Default": "Predvolené", "TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.", "TaskOptimizeDatabase": "Optimalizovať databázu", - "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.", + "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov. Táto úloha môže trvať dlhší čas.", "TaskKeyframeExtractor": "Extraktor kľúčových snímkov", "External": "Externé", "HearingImpaired": "Sluchovo postihnutí", @@ -106,5 +106,20 @@ "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", + "NameExtraBehindTheScenes": "Zo zákulisia", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Vystrihnutá scéna", + "NameExtraFeaturette": "Bonus", + "NameExtraInterview": "Rozhovor", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ukážka", + "NameExtraScene": "Scéna", + "NameExtraShort": "Krátky film", + "NameExtraThemeSong": "Úvodná pieseň", + "NameExtraThemeVideo": "Úvodné video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 8c8ed3254a..6ea625d66c 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -106,5 +106,19 @@ "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", + "NameExtraBehindTheScenes": "V zakulisju", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Izbrisan prizor", + "NameExtraFeaturette": "Kratek dokumentarec o izdelavi filma", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Vzorec", + "NameExtraScene": "Prizor", + "NameExtraShort": "Kratki film", + "NameExtraThemeSong": "Tematska Pesem", + "NameExtraThemeVideo": "Tematski Video", + "NameExtraTrailer": "Napovednik" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index b1f76aafbb..11b25e9795 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -106,5 +106,20 @@ "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", + "NameExtraBehindTheScenes": "Pamje nga prapaskenat", + "NameExtraClip": "Pjesë", + "NameExtraDeletedScene": "Skenë e fshirë", + "NameExtraFeaturette": "Film i shkurtër", + "NameExtraInterview": "Intervistë", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Shembull", + "NameExtraScene": "Skenë", + "NameExtraShort": "Film i shkurtër", + "NameExtraThemeSong": "Kënga e temës", + "NameExtraThemeVideo": "Videoja e temës", + "NameExtraTrailer": "Parapamje", + "NameExtraUnknown": "Shtesë" } diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 56806e25c1..de0e425098 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -106,5 +106,20 @@ "CleanupUserDataTask": "Задатак чишћења корисничких података", "CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.", "TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање", - "TaskDownloadMissingLyricsDescription": "Преузми стихове песама" + "TaskDownloadMissingLyricsDescription": "Преузми стихове песама", + "LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}", + "Original": "Изворно", + "NameExtraBehindTheScenes": "Иза кулиса", + "NameExtraClip": "Исечак", + "NameExtraDeletedScene": "Обрисана сцена", + "NameExtraFeaturette": "Кратки прилог", + "NameExtraInterview": "Интервју", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Узорак", + "NameExtraScene": "Сцена", + "NameExtraShort": "Кратки филм", + "NameExtraThemeSong": "Музичка тема", + "NameExtraThemeVideo": "Спот музичке теме", + "NameExtraTrailer": "Реклама", + "NameExtraUnknown": "Додатак" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 7384967122..7d741bca36 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.", "CleanupUserDataTask": "Uppgift för rensning av användardata", "Original": "Original", - "LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}" + "LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}", + "NameExtraBehindTheScenes": "Bakom kulisserna", + "NameExtraDeletedScene": "Borttagen scen", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Scen", + "NameExtraShort": "Kortfilm", + "NameExtraThemeSong": "Signaturmelodi", + "NameExtraTrailer": "Trailer", + "NameExtraClip": "Klipp", + "NameExtraFeaturette": "Kortfilm", + "NameExtraSample": "Prov", + "NameExtraThemeVideo": "Signaturvideo", + "NameExtraUnknown": "Extra" } 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/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index f613b973db..256f349926 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -106,5 +106,20 @@ "TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்", "TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.", "CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.", - "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி" + "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி", + "LyricDownloadFailureFromForItem": "{0} இலிருந்து {1} க்கு பாடல் வரிகளைப் பதிவிறக்க முடியவில்லை", + "NameExtraBehindTheScenes": "திரைக்குப் பின்னால்", + "NameExtraClip": "துண்டுக்காட்சி", + "NameExtraDeletedScene": "நீக்கப்பட்ட காட்சிகள்", + "NameExtraFeaturette": "திரைப்பின்னணித் தொகுப்பு", + "NameExtraInterview": "நேர்காணல்", + "NameExtraSample": "மாதிரி", + "NameExtraScene": "காட்சி", + "NameExtraShort": "குறுகிய", + "NameExtraThemeSong": "மையப் பாடல்", + "NameExtraThemeVideo": "மையக் காணொளி", + "NameExtraTrailer": "முன்னோட்டம்", + "NameExtraUnknown": "கூடுதல்", + "Original": "அசல்", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 89c2c26748..77e526db74 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -22,20 +22,20 @@ "NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว", "NameSeasonUnknown": "ไม่ทราบซีซัน", "NameSeasonNumber": "ซีซัน {0}", - "NameInstallFailed": "การติดตั้ง {0} ล้มเหลว", + "NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ", "MusicVideos": "มิวสิควิดีโอ", - "Music": "ดนตรี", + "Music": "เพลง", "Movies": "ภาพยนตร์", - "MixedContent": "เนื้อหาผสม", - "Latest": "ล่าสุด", - "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}", - "LabelIpAddressValue": "ที่อยู่ IP: {0}", - "Inherit": "สืบทอด", - "HomeVideos": "โฮมวิดีโอ", - "HeaderNextUp": "ถัดไป", - "HeaderLiveTV": "ทีวีสด", - "HeaderFavoriteShows": "รายการที่ชื่นชอบ", - "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ", + "MixedContent": "เนื้อหาหลากหลายประเภท", + "Latest": "มาใหม่ล่าสุด", + "LabelRunningTimeValue": "ความยาว: {0}", + "LabelIpAddressValue": "หมายเลข IP: {0}", + "Inherit": "ใช้ค่าเริ่มต้น", + "HomeVideos": "วิดีโอส่วนตัว", + "HeaderNextUp": "รายการถัดไป", + "HeaderLiveTV": "ทีวีถ่ายทอดสด", + "HeaderFavoriteShows": "รายการที่ชอบ", + "HeaderFavoriteEpisodes": "ตอนที่ชอบ", "HeaderContinueWatching": "ดูต่อ", "Genres": "ประเภท", "Folders": "โฟลเดอร์", @@ -106,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment", "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay", "CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้", - "CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน" + "CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน", + "LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ", + "Original": "ต้นฉบับ", + "NameExtraBehindTheScenes": "เบื้องหลังการถ่ายทำ", + "NameExtraClip": "คลิปวิดีโอ", + "NameExtraDeletedScene": "ฉากที่ถูกตัดออก", + "NameExtraFeaturette": "คลิปสั้นพิเศษ", + "NameExtraInterview": "บทสัมภาษณ์", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "ตัวอย่าง", + "NameExtraScene": "ฉาก", + "NameExtraShort": "ภาพยนตร์สั้น", + "NameExtraThemeSong": "เพลงประกอบ", + "NameExtraThemeVideo": "วิดีโอธีม", + "NameExtraTrailer": "ตัวอย่างภาพยนตร์", + "NameExtraUnknown": "เนื้อหาพิเศษ" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 0c42d4a55f..67f4ecc4d5 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -1,5 +1,5 @@ { - "AppDeviceValues": "Uygulama: {0}, Aygıt: {1}", + "AppDeviceValues": "Uygulama: {0}, Cihaz: {1}", "Artists": "Sanatçılar", "AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı", "Books": "Kitaplar", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Kullanıcı verisi temizleme görevi", "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler.", "LyricDownloadFailureFromForItem": "{1} şarkı sözleri {0} adresinden indirilemedi", - "Original": "Orijinal" + "Original": "Orijinal", + "NameExtraBehindTheScenes": "Kamera Arkası", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Silinmiş Sahne", + "NameExtraFeaturette": "Kısa film", + "NameExtraInterview": "Röportaj", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Örnek", + "NameExtraScene": "Sahne", + "NameExtraShort": "Kısa", + "NameExtraThemeSong": "Tanıtım Müziği", + "NameExtraThemeVideo": "Tanıtım Videosu", + "NameExtraTrailer": "Fragman", + "NameExtraUnknown": "Fazladan" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index ccb9d915d1..856740545c 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Завдання очищення даних користувача", "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.", "Original": "Оригінал", - "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}" + "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}", + "NameExtraBehindTheScenes": "За лаштунками", + "NameExtraClip": "Кліп", + "NameExtraDeletedScene": "Видалена сцена", + "NameExtraFeaturette": "Фічуретка", + "NameExtraInterview": "Інтерв’ю", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Приклад", + "NameExtraScene": "Сцена", + "NameExtraShort": "Коротко", + "NameExtraThemeSong": "Тематична пісня", + "NameExtraThemeVideo": "Тематичне вiдео", + "NameExtraTrailer": "Трейлер", + "NameExtraUnknown": "Додатково" } diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index b3f24a31e3..a8ec31aed4 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -98,5 +98,28 @@ "TaskDownloadMissingLyrics": "غائب بول ڈاؤن لوڈ کریں", "TaskDownloadMissingLyricsDescription": "گانے کے غائب بول ڈاؤن لوڈ کریں", "TaskAudioNormalization": "آڈیو نارملائزیشن", - "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔" + "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔", + "LyricDownloadFailureFromForItem": "{1} کے لیے {0} سے دھن ڈاؤن لوڈ کرنے میں ناکامی", + "Original": "اصل", + "TaskRefreshTrickplayImages": "Trickplay تصاویر بنائیں", + "TaskRefreshTrickplayImagesDescription": "فعال لائبریریوں میں ویڈیوز کے لیے Trickplay پیش منظر بناتا ہے۔", + "TaskExtractMediaSegments": "میڈیا سیگمنٹ اسکین", + "TaskExtractMediaSegmentsDescription": "MediaSegment فعال پلگ انز سے میڈیا سیگمنٹس اخذ یا حاصل کرتا ہے۔", + "TaskMoveTrickplayImages": "Trickplay تصاویر کا مقام منتقل کریں", + "TaskMoveTrickplayImagesDescription": "لائبریری کی ترتیبات کے مطابق موجودہ Trickplay فائلیں منتقل کرتا ہے۔", + "CleanupUserDataTask": "صارف ڈیٹا صفائی کا ٹاسک", + "CleanupUserDataTaskDescription": "کم از کم 90 دن سے موجود نہ ہونے والے میڈیا سے تمام صارف ڈیٹا (دیکھنے کی حالت، پسندیدہ حیثیت وغیرہ) صاف کرتا ہے۔", + "NameExtraBehindTheScenes": "پسِ پردہ", + "NameExtraClip": "کلپ", + "NameExtraDeletedScene": "حذف شدہ منظر", + "NameExtraFeaturette": "فیچریٹ", + "NameExtraInterview": "انٹرویو", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "نمونہ", + "NameExtraScene": "منظر", + "NameExtraShort": "مختصر", + "NameExtraThemeSong": "تھیم سونگ", + "NameExtraThemeVideo": "تھیم ویڈیو", + "NameExtraTrailer": "ٹریلر", + "NameExtraUnknown": "اضافی" } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 2ba665e2ff..77619b24d0 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -32,7 +32,7 @@ "TasksLibraryCategory": "Thư Viện", "TasksMaintenanceCategory": "Bảo Trì", "VersionNumber": "Phiên Bản {0}", - "UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}", + "UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}", "UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}", "UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}", "UserOnlineFromDevice": "{0} trực tuyến từ {1}", @@ -79,7 +79,7 @@ "HeaderNextUp": "Tiếp Theo", "HeaderFavoriteShows": "Chương Trình Yêu Thích", "HeaderFavoriteEpisodes": "Tập Phim Yêu Thích", - "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}", + "FailedLoginAttemptWithUserName": "Cố gắng đăng nhập thất bại từ {0}", "ChapterNameValue": "Phân Cảnh {0}", "Books": "Sách", "AuthenticationSucceededWithUserName": "{0} xác thực thành công", @@ -95,18 +95,31 @@ "TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.", "External": "Bên ngoài", "HearingImpaired": "Khiếm Thính", - "TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay", - "TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.", + "TaskRefreshTrickplayImages": "Tạo Ảnh Tua Nhanh (Trickplay)", + "TaskRefreshTrickplayImagesDescription": "Tạo ảnh tua nhanh (trịckplay) xem thử cho các video trong các thư viện được kích hoạt.", "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh", "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.", "TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát", "TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu", "TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.", - "TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay", - "TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.", + "TaskMoveTrickplayImages": "Di Chuyển Vị Trí Ảnh Tua Nhanh (Trickplay)", + "TaskMoveTrickplayImagesDescription": "Di chuyển các tệp ảnh tua nhanh (trickplay) hiện có theo cài đặt thư viện.", "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện", "CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng", "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.", "Original": "Gốc", - "LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}" + "LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}", + "NameExtraBehindTheScenes": "Hậu Trường", + "NameExtraDeletedScene": "Cảnh Bị Xóa", + "NameExtraInterview": "Phỏng vấn", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mẫu", + "NameExtraScene": "Cảnh", + "NameExtraShort": "Ngắn", + "NameExtraThemeSong": "Bài Hát Chủ Đề", + "NameExtraThemeVideo": "Video Chủ Đề", + "NameExtraFeaturette": "Nội dung phụ", + "NameExtraClip": "Clip ngắn", + "NameExtraTrailer": "Đoạn giới thiệu", + "NameExtraUnknown": "Nội dung bổ sung" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 18418ae0bc..9590d9c1c9 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "用户数据清理任务", "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。", "LyricDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的歌词", - "Original": "原始" + "Original": "原始", + "NameExtraBehindTheScenes": "幕后花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "删减场景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采访", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "试看", + "NameExtraScene": "精选片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主题曲", + "NameExtraThemeVideo": "主题视频", + "NameExtraTrailer": "预告片", + "NameExtraUnknown": "额外内容" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 8b9665cf9a..9557742d39 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -10,8 +10,8 @@ "Folders": "資料夾", "Genres": "風格", "HeaderContinueWatching": "繼續睇返", - "HeaderFavoriteEpisodes": "心水嘅劇集", - "HeaderFavoriteShows": "心水嘅節目", + "HeaderFavoriteEpisodes": "心水劇集", + "HeaderFavoriteShows": "心水節目", "HeaderLiveTV": "電視直播", "HeaderNextUp": "跟住落嚟", "HomeVideos": "家庭影片", @@ -77,8 +77,8 @@ "TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。", "TaskCleanLogs": "清理日誌資料夾", "TaskRefreshLibrary": "掃描媒體櫃", - "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。", - "TaskRefreshChapterImages": "擷取章節圖片", + "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。", + "TaskRefreshChapterImages": "擷取章節圖像", "TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。", "TaskCleanCache": "清理快取(Cache)資料夾", "TasksChannelsCategory": "網路頻道", @@ -106,5 +106,20 @@ "TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。", "TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置", "CleanupUserDataTask": "清理使用者資料嘅任務", - "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。" + "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。", + "LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞", + "Original": "原始", + "NameExtraBehindTheScenes": "幕後花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "刪減場景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采訪", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "試看", + "NameExtraScene": "精選片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主題曲", + "NameExtraThemeVideo": "主題影片", + "NameExtraTrailer": "預告片", + "NameExtraUnknown": "額外內容" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 5dace3b0b7..bcc5b0b44f 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -106,5 +106,20 @@ "TaskMoveTrickplayImages": "遷移快轉縮圖位置", "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。", "CleanupUserDataTask": "用戶資料清理工作", - "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。" + "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。", + "Original": "原作", + "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞", + "NameExtraBehindTheScenes": "幕後花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "刪減場景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采訪", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "試看", + "NameExtraScene": "精選片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主題曲", + "NameExtraThemeVideo": "主題影片", + "NameExtraTrailer": "預告片", + "NameExtraUnknown": "額外內容" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 843e35afcc..0331ec39e5 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -262,6 +262,24 @@ namespace Emby.Server.Implementations.Localization } /// <inheritdoc /> + public string? GetLanguageDisplayName(string language) + { + if (string.IsNullOrEmpty(language)) + { + return null; + } + + var displayName = FindLanguageInfo(language)?.DisplayName; + if (displayName is null) + { + return null; + } + + // Truncate at the first delimiter to avoid cluttered display names + return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim(); + } + + /// <inheritdoc /> public IReadOnlyList<CountryInfo> GetCountries() { using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); @@ -356,6 +374,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 +403,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 +505,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 +526,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 +624,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/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 409414139c..308faed8cc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists var playlist = _libraryManager.GetItemById(playlistId) as Playlist ?? throw new ArgumentException("No Playlist exists with Id " + playlistId); - // Retrieve all the items to be added to the playlist + // Retrieve all the items to be added to the playlist. var newItems = GetPlaylistItems(newItemIds, user, options) .Where(i => i.SupportsAddingToPlaylist); - // Filter out duplicate items - var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet(); - newItems = newItems - .Where(i => !existingIds.Contains(i.Id)) - .Distinct(); - // Create a list of the new linked children to add to the playlist var childrenToAdd = newItems .Select(LinkedChild.Create) .ToList(); - // Log duplicates that have been ignored, if any - int numDuplicates = newItemIds.Count - childrenToAdd.Count; - if (numDuplicates > 0) - { - _logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name); - } - // Do nothing else if there are no items to add to the playlist if (childrenToAdd.Count == 0) { diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index f699c99d85..f7ad9b9498 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -255,6 +255,14 @@ namespace Emby.Server.Implementations.Plugins } _plugins.Add(plugin); + + // Updating a disabled plugin must not enable it again. + if (plugin.Manifest.Status == PluginStatus.Disabled) + { + ProcessAlternative(plugin); + return; + } + EnablePlugin(plugin); } @@ -387,29 +395,11 @@ namespace Emby.Server.Implementations.Plugins var url = new Uri(packageInfo.ImageUrl); imagePath = Path.Join(path, url.Segments[^1]); - var fileStream = AsyncFile.OpenWrite(imagePath); - Stream? downloadStream = null; - try - { - downloadStream = await HttpClientFactory - .CreateClient(NamedClient.Default) - .GetStreamAsync(url) - .ConfigureAwait(false); - - await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); - } - catch (HttpRequestException ex) + // The catalog is refreshed on every dashboard visit and rewrites the manifest of + // every installed plugin, so only fetch an image that is actually missing. + if (!ImageExists(imagePath)) { - _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); - imagePath = string.Empty; - } - finally - { - await fileStream.DisposeAsync().ConfigureAwait(false); - if (downloadStream is not null) - { - await downloadStream.DisposeAsync().ConfigureAwait(false); - } + imagePath = await DownloadImage(url, imagePath).ConfigureAwait(false); } } @@ -448,6 +438,67 @@ namespace Emby.Server.Implementations.Plugins } } + private static bool ImageExists(string imagePath) + { + var image = new FileInfo(imagePath); + + // A previous download may have been interrupted, leaving an empty file behind. + return image.Exists && image.Length > 0; + } + + private async Task<string> DownloadImage(Uri url, string imagePath) + { + // Download to a temporary file and move it into place, so that neither a failed download + // nor a concurrent one can be observed as a partially written image. + var tempPath = imagePath + "." + Path.GetRandomFileName(); + + try + { + var fileStream = AsyncFile.Create(tempPath); + Stream? downloadStream = null; + try + { + downloadStream = await HttpClientFactory + .CreateClient(NamedClient.Default) + .GetStreamAsync(url) + .ConfigureAwait(false); + + await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); + } + finally + { + await fileStream.DisposeAsync().ConfigureAwait(false); + if (downloadStream is not null) + { + await downloadStream.DisposeAsync().ConfigureAwait(false); + } + } + + File.Move(tempPath, imagePath, true); + + return imagePath; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException) + { + _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); + TryDeleteFile(tempPath); + + return string.Empty; + } + } + + private void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Unable to delete {Path}.", path); + } + } + /// <summary> /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path. /// If no file is found, no reconciliation occurs. @@ -632,9 +683,10 @@ namespace Emby.Server.Implementations.Plugins return; } - var predecessor = _plugins.OrderByDescending(p => p.Version) - .FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version); - if (predecessor is not null) + var successor = _plugins.FirstOrDefault(p => p.Id.Equals(plugin.Id) + && p.Version > plugin.Version + && (p.IsEnabledAndSupported || p.Manifest.Status == PluginStatus.Disabled)); + if (successor is not null) { return; } @@ -763,6 +815,8 @@ namespace Emby.Server.Implementations.Plugins var entry = versions[x]; if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) { + lastName = string.Empty; + if (!TryGetPluginDlls(entry, out var allowedDlls)) { _logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name); @@ -772,15 +826,18 @@ namespace Emby.Server.Implementations.Plugins entry.DllFiles = allowedDlls; + // Only clean up older versions when this version will actually be loaded. if (entry.IsEnabledAndSupported) { lastName = entry.Name; - continue; } + + continue; } if (string.IsNullOrEmpty(lastName)) { + // Unnamed plugin, so there is nothing to match older versions against. continue; } @@ -891,9 +948,9 @@ namespace Emby.Server.Implementations.Plugins if (previousVersion is null) { - // This value is memory only - so that the web will show restart required. - plugin.Manifest.Status = PluginStatus.Restart; - plugin.Manifest.AutoUpdate = false; + // Memory only, so that the web will show restart required. The manifest must keep + // holding the persisted state, or a later save would write the wrong state to disk. + plugin.RestartRequired = true; return; } @@ -906,9 +963,7 @@ namespace Emby.Server.Implementations.Plugins _logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name); } - // This value is memory only - so that the web will show restart required. - plugin.Manifest.Status = PluginStatus.Restart; - plugin.Manifest.AutoUpdate = false; + plugin.RestartRequired = true; } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index b2dc89be28..29b633530f 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; @@ -173,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol) { t.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), + string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()), false, cancellationToken).ConfigureAwait(false); toSaveDbItems.Add(t); @@ -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..687947616f 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; @@ -10,13 +11,14 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; /// <summary> -/// Optimizes Jellyfin's database by issuing a VACUUM command. +/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands. /// </summary> 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,7 +73,16 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); + // 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("Vacuuming and analyzing jellyfin.db..."); try { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 6e4e5c7808..092a621bfc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -4,11 +4,19 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Emby.Server.Implementations.Library.Validators; +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 +28,10 @@ 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 ILogger<PeopleValidator> _validatorLogger; + private readonly IItemTypeLookup _itemTypeLookup; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class. @@ -27,11 +39,26 @@ 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="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> 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, + ILogger<PeopleValidator> validatorLogger, + IItemTypeLookup itemTypeLookup) { _libraryManager = libraryManager; _localization = localization; _dbContextFactory = dbContextFactory; + _fileSystem = fileSystem; + _logger = logger; + _validatorLogger = validatorLogger; + _itemTypeLookup = itemTypeLookup; } /// <inheritdoc /> @@ -71,16 +98,24 @@ 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) + .OrderBy(e => e.Key.Name) + .ThenBy(e => e.Key.PersonType) .Select(e => e.Select(f => f.Id).ToArray()); var total = dupQuery.Count(); @@ -123,7 +158,110 @@ 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 new PeopleValidator(_libraryManager, _validatorLogger) + .Run(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]; + + List<Guid> peopleIds; + + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + // Read the candidates in one go rather than paging them. A refresh stamps the person and takes + // it out of this set, so a growing offset over a shrinking set walks past people it never visits. + peopleIds = 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)) + .OrderBy(b => b.Id) + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + _logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count); + + if (peopleIds.Count == 0) + { + progress.Report(100); + return; + } + + var numComplete = 0; + var numRefreshed = 0; + + foreach (var personId in peopleIds) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false)) + { + numRefreshed++; + } + + numComplete++; + progress.Report(100.0 * numComplete / peopleIds.Count); + } + + _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.FullRefresh, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh + }; + + 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/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index 31153af20f..dd3da2214a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask { _logger.LogError(ex, "Error updating {Name}", package.Name); } + catch (TimeoutException ex) + { + // One slow download must not abort the updates for the remaining plugins. + _logger.LogError(ex, "Error downloading {Name}", package.Name); + } catch (InvalidDataException ex) { _logger.LogError(ex, "Error updating {Name}", package.Name); diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 18811ef3a9..94215bed79 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session { if (!session.SessionControllers.Any(i => i.IsSessionActive)) { - var key = GetSessionKey(session.Client, session.DeviceId); + var key = GetSessionKey(session.Client, session.DeviceId, session.UserId); _activeConnections.TryRemove(key, out _); if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId)) @@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session _activeLiveStreamSessions.TryRemove(liveStreamId, out _); } } + else + { + liveStreamNeedsToBeClosed = true; + } if (liveStreamNeedsToBeClosed) { @@ -365,7 +369,7 @@ namespace Emby.Server.Implementations.Session if (session is not null) { - var key = GetSessionKey(session.Client, session.DeviceId); + var key = GetSessionKey(session.Client, session.DeviceId, session.UserId); _activeConnections.TryRemove(key, out _); @@ -471,8 +475,11 @@ namespace Emby.Server.Implementations.Session } } - private static string GetSessionKey(string appName, string deviceId) - => appName + deviceId; + // The user is part of the key because the client name and the device id are taken from the + // request headers and are not bound to the access token. Without it, any authenticated user + // could claim another user's client/device pair and take over their session. + private static string GetSessionKey(string appName, string deviceId, Guid userId) + => appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture); /// <summary> /// Gets the connection. @@ -496,7 +503,7 @@ namespace Emby.Server.Implementations.Session ArgumentException.ThrowIfNullOrEmpty(deviceId); - var key = GetSessionKey(appName, deviceId); + var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty); SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user); SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession); if (ReferenceEquals(newSession, sessionInfo)) @@ -637,8 +644,7 @@ namespace Emby.Server.Implementations.Session if (playingSessions.Count > 0) { var idle = playingSessions - .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5) - .ToList(); + .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5); foreach (var session in idle) { @@ -652,7 +658,7 @@ namespace Emby.Server.Implementations.Session ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id, SessionId = session.Id, MediaSourceId = session.PlayState?.MediaSourceId, - PositionTicks = session.PlayState?.PositionTicks + PositionTicks = session.LastPlaybackCheckInPositionTicks }).ConfigureAwait(false); } catch (Exception ex) @@ -726,6 +732,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> @@ -756,9 +787,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); } } @@ -890,9 +922,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); } } @@ -952,6 +985,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); } } @@ -1083,9 +1130,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); } } @@ -1138,6 +1186,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; } @@ -1484,11 +1540,52 @@ namespace Emby.Server.Implementations.Session return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken); } - private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession) + private void AssertCanControl(SessionInfo session, SessionInfo controllingSession) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(controllingSession); + + var controllingUserId = controllingSession.UserId; + + // Controlling a session is always allowed when: + // - the caller has no associated user (an API key, which is a privileged context), + // - the target session is public (has no owning user), or + // - the caller's user is associated with the target session. + // Controlling a session owned by a different user requires the + // EnableRemoteControlOfOtherUsers permission. + if (controllingUserId.IsEmpty() + || session.UserId.IsEmpty() + || session.ContainsUser(controllingUserId)) + { + return; + } + + var controllingUser = _userManager.GetUserById(controllingUserId); + if (controllingUser is null + || !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) + { + throw new SecurityException("The current user does not have permission to remote control other users."); + } + } + + private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId) + { + var controllingUserId = controllingSession.UserId; + + // Playback reported by a session is also written to the user data of its additional users, + // so attaching anyone but the calling user requires administrative privileges. + if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId)) + { + return; + } + + var controllingUser = _userManager.GetUserById(controllingUserId); + if (controllingUser is null + || !controllingUser.HasPermission(PermissionKind.IsAdministrator)) + { + throw new SecurityException("The current user does not have permission to attach another user to a session."); + } } /// <summary> @@ -1506,16 +1603,24 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Adds the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception> + /// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception> /// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception> - public void AddAdditionalUser(string sessionId, Guid userId) + public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + var controllingSession = GetSession(controllingSessionId); + AssertCanControl(session, controllingSession); + AssertCanAttachUser(controllingSession, userId); + } + if (session.UserId.Equals(userId)) { throw new ArgumentException("The requested user is already the primary user of the session."); @@ -1523,7 +1628,8 @@ namespace Emby.Server.Implementations.Session if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId))) { - var user = _userManager.GetUserById(userId); + var user = _userManager.GetUserById(userId) + ?? throw new ArgumentException("The requested user does not exist."); var newUser = new SessionUserInfo { UserId = userId, @@ -1537,16 +1643,22 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Removes the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception> + /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception> /// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception> - public void RemoveAdditionalUser(string sessionId, Guid userId) + public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + if (session.UserId.Equals(userId)) { throw new ArgumentException("The requested user is already the primary user of the session."); @@ -1750,14 +1862,21 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Reports the capabilities. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="capabilities">The capabilities.</param> - public void ReportCapabilities(string sessionId, ClientCapabilities capabilities) + /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception> + public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + ReportCapabilities(session, capabilities, true); } @@ -1852,13 +1971,18 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public void ReportNowViewingItem(string sessionId, string itemId) + public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId) { ArgumentException.ThrowIfNullOrEmpty(itemId); var item = _libraryManager.GetItemById(new Guid(itemId)); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + session.NowViewingItem = GetItemInfo(item, null); } diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 6a26e92e14..e81edc82c6 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session if (inactive.Count > 0) { - _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); + _logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); } foreach (var webSocket in inactive) @@ -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/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs index f10e7fcbb7..4159f8cf7d 100644 --- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs @@ -3,35 +3,15 @@ using System; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { - public class DateLastMediaAddedComparer : IUserBaseItemComparer + public class DateLastMediaAddedComparer : IBaseItemComparer { /// <summary> - /// Gets or sets the user. - /// </summary> - /// <value>The user.</value> - public User User { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } - - /// <summary> - /// Gets or sets the user data manager. - /// </summary> - /// <value>The user data manager.</value> - public IUserDataManager UserDataManager { get; set; } - - /// <summary> /// Gets the name. /// </summary> /// <value>The name.</value> diff --git a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs index 8c8b8824f3..30b268bb60 100644 --- a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs @@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.Sorting return x.PremiereDate.Value; } - if (x.ProductionYear.HasValue) + if (x.ProductionYear is not null) { try { diff --git a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs index 9aec87f183..8774bd8d4f 100644 --- a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs +++ b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs @@ -39,7 +39,7 @@ namespace Emby.Server.Implementations.Sorting return 0; } - if (x.ProductionYear.HasValue) + if (x.ProductionYear is not null) { return x.ProductionYear.Value; } diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index c2e834ad58..923bfc67aa 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -91,6 +91,18 @@ namespace Emby.Server.Implementations.SyncPlay public long DefaultPing { get; } = 500; /// <summary> + /// Gets the maximum ping, in milliseconds, accepted from a session. + /// </summary> + /// <remarks> + /// Pings are reported by clients and are scaled into the delays used to schedule playback, + /// so an unbounded value lets a single session push the whole group's resume point + /// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a + /// usable measurement for synchronisation. + /// </remarks> + /// <value>The maximum ping.</value> + public long MaxPing { get; } = 10000; + + /// <summary> /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. /// </summary> /// <value>The maximum time offset error.</value> @@ -206,7 +218,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; } @@ -437,7 +450,7 @@ namespace Emby.Server.Implementations.SyncPlay { if (_participants.TryGetValue(session.Id, out GroupMember value)) { - value.Ping = ping; + value.Ping = Math.Clamp(ping, 0, MaxPing); } } @@ -450,7 +463,9 @@ namespace Emby.Server.Implementations.SyncPlay max = Math.Max(max, session.Ping); } - return max; + // A group with no participants has no ping to report. Returning long.MinValue would + // overflow the callers that scale this value into ticks, so fall back to the default. + return max == long.MinValue ? DefaultPing : max; } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index b45d754554..b88ee33358 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -181,8 +181,8 @@ namespace Emby.Server.Implementations.SyncPlay { if (existingGroup.GroupId.Equals(request.GroupId)) { - // Restore session. - UpdateSessionsCounter(session.UserId, 1); + // Restore session. The session is already in the group and has already + // been counted, so the counter must not be incremented a second time. group.SessionJoin(session, request, cancellationToken); return; } @@ -332,8 +332,11 @@ namespace Emby.Server.Implementations.SyncPlay // Group lock required as Group is not thread-safe. lock (group) { - // Make sure that session still belongs to this group. - if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId)) + // Make sure that session still belongs to this group. The lookup can fail + // outright when the session left while this request was waiting on the group + // lock, which is exactly the case this re-check exists to catch. + if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) + || !checkGroup.GroupId.Equals(group.GroupId)) { // Drop request. return; @@ -400,7 +403,7 @@ namespace Emby.Server.Implementations.SyncPlay // Update sessions counter. var newSessionsCounter = _activeUsers.AddOrUpdate( userId, - 1, + toAdd, (_, sessionsCounter) => sessionsCounter + toAdd); // Should never happen. 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 ef53e3b326..cccdb3e6aa 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; @@ -10,7 +11,6 @@ using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; @@ -32,6 +32,11 @@ namespace Emby.Server.Implementations.Updates /// </summary> public class InstallationManager : IInstallationManager { + private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']); + // Budget for the whole package download. The response headers are already bounded by the + // HttpClient timeout; this covers reading the package body, which can be large and slow. + private static readonly TimeSpan PackageDownloadTimeout = TimeSpan.FromMinutes(10); + /// <summary> /// The logger. /// </summary> @@ -79,8 +84,8 @@ namespace Emby.Server.Implementations.Updates IServerConfigurationManager config, IPluginManager pluginManager) { - _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); - _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>(); + _currentInstallations = []; + _completedInstallationsInternal = []; _logger = logger; _applicationHost = appHost; @@ -338,8 +343,9 @@ namespace Emby.Server.Implementations.Updates _applicationHost.NotifyPendingRestart(); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (linkedToken.IsCancellationRequested) { + // Only an actually cancelled token is a cancellation. lock (_currentInstallationsLock) { _currentInstallations.Remove(tuple); @@ -353,7 +359,7 @@ namespace Emby.Server.Implementations.Updates } catch (Exception ex) { - _logger.LogError(ex, "Package installation failed"); + _logger.LogError(ex, "Package installation failed: {Name} {Version}", package.Name, package.Version); lock (_currentInstallationsLock) { @@ -497,8 +503,9 @@ namespace Emby.Server.Implementations.Updates var plugins = _pluginManager.Plugins; foreach (var plugin in plugins) { - // Don't auto update when plugin marked not to, or when it's disabled. - if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled) + // Don't auto update when plugin marked not to, or when it's disabled or pending removal. + if (plugin.Manifest?.AutoUpdate == false + || plugin.Manifest?.Status is PluginStatus.Disabled or PluginStatus.Deleted) { continue; } @@ -521,15 +528,57 @@ 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(); - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) + var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath)); + var resolvedTarget = Path.GetFullPath(targetDir); + if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.", + package.Name, + resolvedTarget, + pluginsRoot); + throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); + } + + // ResponseHeadersRead keeps the body out of the HttpClient timeout, which otherwise covers + // the whole download; the package gets the longer budget below instead. + using var downloadTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + downloadTokenSource.CancelAfter(PackageDownloadTimeout); + var downloadToken = downloadTokenSource.Token; + + var buffer = new MemoryStream(); + await using (buffer.ConfigureAwait(false)) { + try + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(new Uri(package.SourceUrl), HttpCompletionOption.ResponseHeadersRead, downloadToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + // The package is read twice, for the checksum and for the extraction, so it has + // to be buffered: the response stream is not seekable. + await response.Content.CopyToAsync(buffer, downloadToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // Either our budget above or the HttpClient timeout ran out. + throw new TimeoutException( + $"Downloading the package {package.Name} {package.Version} from {package.SourceUrl} timed out.", + ex); + } + + buffer.Position = 0; + Stream stream = buffer; + // CA5351: Do Not Use Broken Cryptographic Algorithms #pragma warning disable CA5351 cancellationToken.ThrowIfCancellationRequested(); @@ -572,6 +621,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)) |
