aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs43
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs3
-rw-r--r--Emby.Server.Implementations/Devices/DeviceId.cs13
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs201
-rw-r--r--Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs65
-rw-r--r--Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs68
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs6
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs7
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs2
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs44
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs480
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs101
-rw-r--r--Emby.Server.Implementations/Library/PathExtensions.cs81
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs15
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs8
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs10
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs13
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs21
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs26
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs13
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs8
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchManager.cs453
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs38
-rw-r--r--Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs230
-rw-r--r--Emby.Server.Implementations/Library/SearchEngine.cs200
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs14
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs42
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs106
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs220
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs12
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs41
-rw-r--r--Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs64
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs133
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/az.json19
-rw-r--r--Emby.Server.Implementations/Localization/Core/be.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/bg-BG.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/bs.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/ca.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json33
-rw-r--r--Emby.Server.Implementations/Localization/Core/de.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json21
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-US.json13
-rw-r--r--Emby.Server.Implementations/Localization/Core/eo.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-AR.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_419.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/et.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/eu.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json121
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr-CA.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/gl.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json11
-rw-r--r--Emby.Server.Implementations/Localization/Core/hi.json18
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/hy.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/id.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/is.json28
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/kn.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/lb.json18
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json59
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/ms.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/oc.json26
-rw-r--r--Emby.Server.Implementations/Localization/Core/pa.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-BR.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json124
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt.json143
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/si.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json19
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/sq.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/sr.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/sw.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/ta.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/th.json41
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/uk.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/ur_PK.json25
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json27
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json25
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json17
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs70
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/gr.json27
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/se.json13
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs15
-rw-r--r--Emby.Server.Implementations/Plugins/PluginManager.cs119
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs5
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs3
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs1
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs20
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs146
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs5
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs162
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs17
-rw-r--r--Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs22
-rw-r--r--Emby.Server.Implementations/Sorting/PremiereDateComparer.cs2
-rw-r--r--Emby.Server.Implementations/Sorting/ProductionYearComparer.cs2
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs21
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs13
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs92
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs93
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))