diff options
Diffstat (limited to 'Emby.Server.Implementations')
9 files changed, 162 insertions, 43 deletions
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index a2d3e14439..59e75691dc 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -185,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)) @@ -293,7 +300,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ItemCounts)) { - SetItemByNameInfo(dto, user); + SetItemByNameInfo(dto, user, itemCountsBatch); } returnItems[index] = dto; @@ -518,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; diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index e6fa94fbef..0044fcd4dc 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1801,6 +1801,18 @@ namespace Emby.Server.Implementations.Library return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query); } + /// <inheritdoc/> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user) + { + 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); diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 57d1f7c770..b1547e72fe 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -260,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); @@ -276,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/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 610bc286b8..ee03c471ea 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -119,5 +119,7 @@ "NameExtraShort": "Βίντεο μικρού μήκους", "NameExtraThemeSong": "Θεματικό Τραγούδι", "NameExtraThemeVideo": "Θεματικό Βίντεο", - "NameExtraTrailer": "τρέιλερ ταινίας" + "NameExtraTrailer": "τρέιλερ ταινίας", + "NameExtraUnknown": "Πρόσθετα", + "NameExtraClip": "Απόσπασμα" } diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index bd15bac865..023ed6ec5e 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -7,7 +7,7 @@ "AppDeviceValues": "App: {0}, Eind: {1}", "Books": "Bøkur", "ChapterNameValue": "Kapittul {0}", - "Favorites": "Yndis", + "Favorites": "Yndislista", "Folders": "Skjáttur", "Forced": "Kravt", "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", @@ -72,7 +72,7 @@ "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 á {1}", + "UserOfflineFromDevice": "{0} breyt av frá {1}", "UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt", "TasksChannelsCategory": "Alnetsrásir", "TaskCleanActivityLog": "Reinsa virksemisskrá", @@ -83,7 +83,7 @@ "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": "Koyrsla ið reinsar brúkaradátur", + "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.", @@ -112,10 +112,10 @@ "NameExtraFeaturette": "Stuttur heimildarfilmur", "TaskAudioNormalization": "Ljóðjavnan", "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.", - "NameExtraSample": "Kut", + "NameExtraSample": "Sýnislutur", "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir", "TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.", - "TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað", + "TaskMoveTrickplayImages": "Flyt Trickplay-myndir", "TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.", "NameExtraThemeVideo": "Eyðkenniskykmynd", "NameExtraDeletedScene": "Úrtikin mynd", diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 9573eeefe4..e49914c838 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 zoznamov prehrávania. Táto úloha môže trvať dlhšiu dobu.", + "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z videosú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í", diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 8d29d6a512..f7ad9b9498 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -395,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 + // 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)) { - downloadStream = await HttpClientFactory - .CreateClient(NamedClient.Default) - .GetStreamAsync(url) - .ConfigureAwait(false); - - await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); - } - catch (HttpRequestException ex) - { - _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); } } @@ -456,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. 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/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 174234b96b..cccdb3e6aa 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -11,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; @@ -34,6 +33,9 @@ namespace Emby.Server.Implementations.Updates 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. @@ -82,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; @@ -341,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); @@ -356,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) { @@ -546,12 +549,36 @@ namespace Emby.Server.Implementations.Updates throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); } - 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)) + // 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(); |
