aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs7
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs140
-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.cs31
-rw-r--r--Emby.Server.Implementations/IO/FileRefresher.cs27
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs39
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs13
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs175
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs15
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchManager.cs14
-rw-r--r--Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs7
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs15
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs29
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs41
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs41
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs129
-rw-r--r--Emby.Server.Implementations/Localization/Core/be.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/bs.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json22
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/lb.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/pa.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json11
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/th.json41
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ur_PK.json25
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json2
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs48
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/it.json22
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/no.json14
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs10
-rw-r--r--Emby.Server.Implementations/Plugins/PluginManager.cs87
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs4
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs9
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs5
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs4
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs4
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs143
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs82
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs47
53 files changed, 1265 insertions, 335 deletions
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index 84d50f5121..cd93f76d26 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -9,6 +9,7 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Collections;
+using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
@@ -114,7 +115,7 @@ namespace Emby.Server.Implementations.Collections
_libraryManager.RootFolder.Children = null;
- return FindFolders(path).First();
+ return FindFolders(path).FirstOrDefault();
}
internal string GetCollectionsFolderPath()
@@ -167,7 +168,7 @@ namespace Emby.Server.Implementations.Collections
if (parentFolder is null)
{
- throw new ArgumentException(nameof(parentFolder));
+ throw new InvalidOperationException("Unable to resolve the collections library folder, so the collection cannot be created.");
}
var path = Path.Combine(parentFolder.Path, folderName);
@@ -235,7 +236,7 @@ namespace Emby.Server.Implementations.Collections
List<BaseItem>? itemList = null;
- var linkedChildrenList = collection.GetLinkedChildren();
+ var linkedChildrenList = collection.GetLinkedChildren(DtoOptions.StoredColumnsOnly);
var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList();
foreach (var id in ids)
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index a2d3e14439..e539508644 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
+using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
@@ -185,15 +186,18 @@ namespace Emby.Server.Implementations.Dto
allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
}
- // Batch-fetch child counts for all folders to avoid N+1 queries
+ // 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))
+ if (user is not null && options.ContainsField(ItemFields.ChildCount))
{
- var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
- if (folderIds.Count > 0)
- {
- childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
- }
+ childCountBatch = GetChildCountBatch(accessibleItems, user);
}
// Batch-fetch played/total counts for all folders to avoid N+1 queries
@@ -293,7 +297,7 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.ItemCounts))
{
- SetItemByNameInfo(dto, user);
+ SetItemByNameInfo(dto, user, itemCountsBatch);
}
returnItems[index] = dto;
@@ -518,14 +522,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;
@@ -685,24 +711,102 @@ namespace Emby.Server.Implementations.Dto
};
}
- private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch)
+ private Dictionary<Guid, int>? GetChildCountBatch(IReadOnlyList<BaseItem> items, User user)
{
- // Right now this is too slow to calculate for top level folders on a per-user basis
- // Just return something so that apps that are expecting a value won't think the folders are empty
- if (folder is ICollectionFolder || folder is UserView)
+ Dictionary<Guid, IReadOnlyList<Guid>>? sources = null;
+ foreach (var folder in items.OfType<Folder>())
+ {
+ var sourceIds = GetChildCountSourceIds(folder, user);
+ if (sourceIds.Count > 0)
+ {
+ (sources ??= new Dictionary<Guid, IReadOnlyList<Guid>>())[folder.Id] = sourceIds;
+ }
+ }
+
+ if (sources is null)
{
- return Random.Shared.Next(1, 10);
+ return null;
}
+ var counts = _libraryManager.GetChildCountBatch(
+ sources.Values.SelectMany(ids => ids).Distinct().ToList(),
+ user);
+
+ var result = new Dictionary<Guid, int>(sources.Count);
+ foreach (var (folderId, sourceIds) in sources)
+ {
+ var total = 0;
+ foreach (var sourceId in sourceIds)
+ {
+ total += counts.GetValueOrDefault(sourceId);
+ }
+
+ result[folderId] = total;
+ }
+
+ return result;
+ }
+
+ private IReadOnlyList<Guid> GetChildCountSourceIds(Folder folder, User user)
+ {
+ if (folder is CollectionFolder collectionFolder)
+ {
+ return collectionFolder.PhysicalFolderIds;
+ }
+
+ if (folder is not UserView view)
+ {
+ return [folder.Id];
+ }
+
+ // Only a view that stands for a library proxies it. The sub-views a movie or show view
+ // is built from hang off the same library but hold a query, not the library's children.
+ if (!UserView.EnableOriginalFolder(view.ViewType)
+ && view.ViewType is not (CollectionType.movies or CollectionType.tvshows))
+ {
+ return [];
+ }
+
+ // A view over a single library proxies that library, whatever the view type.
+ var parentId = view.DisplayParentId.IsEmpty() ? view.ParentId : view.DisplayParentId;
+ if (!parentId.IsEmpty()
+ && !parentId.Equals(view.Id)
+ && _libraryManager.GetItemById(parentId) is Folder parent
+ && parent is not UserView)
+ {
+ return GetChildCountSourceIds(parent, user);
+ }
+
+ // A grouped view has no single parent: it stands for every library the user grouped
+ // into it, the same set UserViewManager builds the view from.
+ if (view.ViewType is CollectionType.movies or CollectionType.tvshows)
+ {
+ return _libraryManager.GetUserRootFolder()
+ .GetChildren(user, true)
+ .OfType<CollectionFolder>()
+ .Where(f => user.IsFolderGrouped(f.Id)
+ && (f.CollectionType == view.ViewType || f.CollectionType is null))
+ .SelectMany(f => f.PhysicalFolderIds)
+ .Distinct()
+ .ToList();
+ }
+
+ return [];
+ }
+
+ private int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch)
+ {
// Use pre-fetched batch data if available
if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count))
{
return count;
}
- // 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);
+ // No batch covered this folder.
+ var single = GetChildCountBatch([folder], user);
+ return single is not null && single.TryGetValue(folder.Id, out var singleCount)
+ ? singleCount
+ : folder.GetChildCount(user);
}
private static void SetBookProperties(BaseItemDto dto, Book item)
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 dc7f972c13..d7319f80f4 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -127,7 +127,7 @@ namespace Emby.Server.Implementations.HttpServer
{
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
}
- catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
+ catch (Exception ex) when (IsConnectionGone(ex))
{
// ObjectDisposedException/OperationCanceledException: the socket was torn
// down underneath us (e.g. by the keep-alive watchdog after the connection
@@ -158,7 +158,15 @@ namespace Emby.Server.Implementations.HttpServer
if (receiveResult.EndOfMessage)
{
- await ProcessInternal(pipe.Reader).ConfigureAwait(false);
+ try
+ {
+ await ProcessInternal(pipe.Reader).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsConnectionGone(ex))
+ {
+ _logger.LogWarning("WS {IP} error sending data: {Message}", RemoteEndPoint, ex.Message);
+ break;
+ }
}
}
while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting)
@@ -170,13 +178,24 @@ namespace Emby.Server.Implementations.HttpServer
|| _socket.State == WebSocketState.CloseReceived
|| _socket.State == WebSocketState.CloseSent)
{
- await _socket.CloseAsync(
- WebSocketCloseStatus.NormalClosure,
- string.Empty,
- cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _socket.CloseAsync(
+ WebSocketCloseStatus.NormalClosure,
+ string.Empty,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsConnectionGone(ex))
+ {
+ // The peer is already gone, there is nobody left to send the close frame to.
+ _logger.LogDebug("WS {IP} error closing connection: {Message}", RemoteEndPoint, ex.Message);
+ }
}
}
+ private static bool IsConnectionGone(Exception ex)
+ => ex is WebSocketException or ObjectDisposedException or OperationCanceledException;
+
private async Task ProcessInternal(PipeReader reader)
{
ReadResult result = await reader.ReadAsync().ConfigureAwait(false);
diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs
index f634084034..b31cf0f1f5 100644
--- a/Emby.Server.Implementations/IO/FileRefresher.cs
+++ b/Emby.Server.Implementations/IO/FileRefresher.cs
@@ -109,6 +109,11 @@ namespace Emby.Server.Implementations.IO
lock (_timerLock)
{
+ if (_disposed)
+ {
+ return;
+ }
+
paths = _affectedPaths.ToList();
}
@@ -129,11 +134,12 @@ namespace Emby.Server.Implementations.IO
private void ProcessPathChanges(List<string> paths)
{
- IEnumerable<BaseItem> itemsToRefresh = paths
+ var itemsToRefresh = paths
.Distinct()
- .Select(GetAffectedBaseItem)
- .Where(item => item is not null)
- .DistinctBy(x => x!.Id)!; // Removed null values in the previous .Where()
+ .Select(TryGetAffectedBaseItem)
+ .OfType<BaseItem>()
+ .DistinctBy(x => x.Id)
+ .ToList();
foreach (var item in itemsToRefresh)
{
@@ -155,6 +161,19 @@ namespace Emby.Server.Implementations.IO
}
}
+ private BaseItem? TryGetAffectedBaseItem(string path)
+ {
+ try
+ {
+ return GetAffectedBaseItem(path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error finding the item affected by changes to {Path}", path);
+ return null;
+ }
+ }
+
/// <summary>
/// Gets the affected base item.
/// </summary>
diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs
index 1bf0f8c76c..e51c863f86 100644
--- a/Emby.Server.Implementations/IO/LibraryMonitor.cs
+++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs
@@ -3,11 +3,13 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
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 +23,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>
@@ -38,6 +41,12 @@ namespace Emby.Server.Implementations.IO
/// </summary>
private readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new(StringComparer.OrdinalIgnoreCase);
+ /// <summary>
+ /// Incremented by every <see cref="Stop"/> so watchers still being created on a background
+ /// task can tell that the sweep they should have been caught by has already run.
+ /// </summary>
+ private int _watcherGeneration;
+
private bool _disposed;
/// <summary>
@@ -47,6 +56,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 +64,7 @@ namespace Emby.Server.Implementations.IO
ILibraryManager libraryManager,
IServerConfigurationManager configurationManager,
IFileSystem fileSystem,
+ IDirectoryService directoryService,
IHostApplicationLifetime appLifetime,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
{
@@ -61,10 +72,11 @@ namespace Emby.Server.Implementations.IO
_logger = logger;
_configurationManager = configurationManager;
_fileSystem = fileSystem;
+ _directoryService = directoryService;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
appLifetime.ApplicationStarted.Register(Start);
- appLifetime.ApplicationStopping.Register(Stop);
+ appLifetime.ApplicationStopping.Register(Dispose);
}
/// <inheritdoc />
@@ -115,6 +127,11 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc />
public void Start()
{
+ if (_disposed)
+ {
+ return;
+ }
+
_libraryManager.ItemAdded += OnLibraryManagerItemAdded;
_libraryManager.ItemRemoved += OnLibraryManagerItemRemoved;
@@ -228,6 +245,8 @@ namespace Emby.Server.Implementations.IO
return;
}
+ var generation = Volatile.Read(ref _watcherGeneration);
+
// Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do them all in parallel
Task.Run(() =>
{
@@ -251,7 +270,11 @@ namespace Emby.Server.Implementations.IO
newWatcher.Changed += OnWatcherChanged;
newWatcher.Error += OnWatcherError;
- if (_fileSystemWatchers.TryAdd(path, newWatcher))
+ if (_disposed || Volatile.Read(ref _watcherGeneration) != generation)
+ {
+ DisposeWatcher(newWatcher, false);
+ }
+ else if (_fileSystemWatchers.TryAdd(path, newWatcher))
{
newWatcher.EnableRaisingEvents = true;
_logger.LogInformation("Watching directory {Path}", path);
@@ -352,6 +375,11 @@ namespace Emby.Server.Implementations.IO
{
ArgumentException.ThrowIfNullOrEmpty(path);
+ if (_disposed)
+ {
+ return;
+ }
+
if (IgnorePatterns.ShouldIgnore(path))
{
return;
@@ -363,6 +391,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)
{
@@ -445,6 +475,8 @@ namespace Emby.Server.Implementations.IO
/// </summary>
public void Stop()
{
+ Interlocked.Increment(ref _watcherGeneration);
+
_libraryManager.ItemAdded -= OnLibraryManagerItemAdded;
_libraryManager.ItemRemoved -= OnLibraryManagerItemRemoved;
@@ -489,8 +521,9 @@ namespace Emby.Server.Implementations.IO
return;
}
- Stop();
+ // Set before stopping so anything racing us stops handing out new work.
_disposed = true;
+ Stop();
}
}
}
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index ede9b27592..db743c8d31 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -489,11 +489,18 @@ namespace Emby.Server.Implementations.IO
ArgumentException.ThrowIfNullOrEmpty(parentPath);
ArgumentException.ThrowIfNullOrEmpty(path);
- return path.Contains(
- Path.TrimEndingDirectorySeparator(parentPath) + Path.DirectorySeparatorChar,
- _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
+ var parent = Path.TrimEndingDirectorySeparator(parentPath);
+
+ // The parent has to be an anchored prefix of the path, otherwise unrelated paths that merely
+ // contain the parent as a segment (e.g. /media and /data/media/tv) would be treated as related.
+ return path.Length > parent.Length
+ && path.StartsWith(parent, _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)
+ && (Path.EndsInDirectorySeparator(parent) || IsDirectorySeparator(path[parent.Length]));
}
+ private static bool IsDirectorySeparator(char c)
+ => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
+
/// <inheritdoc />
public virtual bool AreEqual(string path1, string path2)
{
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 3db8265f6e..caba304888 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;
@@ -75,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;
@@ -88,6 +86,7 @@ namespace Emby.Server.Implementations.Library
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;
@@ -122,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>
@@ -148,7 +146,6 @@ namespace Emby.Server.Implementations.Library
IFileSystem fileSystem,
Lazy<IProviderManager> providerManagerFactory,
Lazy<IUserViewManager> userViewManagerFactory,
- IMediaEncoder mediaEncoder,
IItemRepository itemRepository,
IItemPersistenceService persistenceService,
INextUpService nextUpService,
@@ -174,7 +171,6 @@ namespace Emby.Server.Implementations.Library
_fileSystem = fileSystem;
_providerManagerFactory = providerManagerFactory;
_userViewManagerFactory = userViewManagerFactory;
- _mediaEncoder = mediaEncoder;
_itemRepository = itemRepository;
_persistenceService = persistenceService;
_nextUpService = nextUpService;
@@ -189,6 +185,7 @@ namespace Emby.Server.Implementations.Library
_pathManager = pathManager;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
_localization = localization;
+ _directoryService = directoryService;
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
@@ -321,7 +318,7 @@ namespace Emby.Server.Implementations.Library
if (wizardChanged)
{
- _taskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>();
+ QueueLibraryScan();
}
}
@@ -881,7 +878,18 @@ namespace Emby.Server.Implementations.Library
wrongTypeItem.GetType().Name,
expectedVideoType.Name,
path);
- DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false });
+
+ // A full DeleteItem would save the primary version, which resolves its
+ // alternates again and re-enters here before this row is gone.
+ DeleteItemsUnsafeFast([wrongTypeItem]);
+
+ // The fast path skips the parent bookkeeping, and the stale item is listed
+ // under its ParentId, so that folder's cached listing has to be dropped.
+ if (wrongTypeItem.GetParent() is Folder staleParent)
+ {
+ staleParent.Children = null;
+ staleParent.UserData = null;
+ }
}
}
@@ -1210,6 +1218,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);
@@ -1222,6 +1236,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>
@@ -1354,15 +1395,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>
@@ -1489,6 +1521,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
@@ -1506,20 +1542,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++;
@@ -1528,6 +1589,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);
@@ -1745,6 +1812,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);
@@ -3720,6 +3799,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
{
@@ -3727,7 +3810,7 @@ namespace Emby.Server.Implementations.Library
if (refreshLibrary)
{
- StartScanInBackground();
+ _ = StartScanInBackground();
}
else
{
@@ -3746,27 +3829,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)
{
@@ -3800,11 +3870,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;
@@ -3813,13 +3878,16 @@ namespace Emby.Server.Implementations.Library
}
}
- private void StartScanInBackground()
+ internal Task StartScanInBackground()
{
- Task.Run(() =>
+ // An active scan already handles library structure changes, so this request can be dropped.
+ if (IsScanRunning)
{
- // No need to start if scanning the library because it will handle it
- ValidateMediaLibrary(new Progress<double>(), CancellationToken.None);
- });
+ return Task.CompletedTask;
+ }
+
+ // Queue instead of restarting so a scan that starts after the check is allowed to finish.
+ return Task.Run(QueueLibraryScan);
}
public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath)
@@ -3920,6 +3988,7 @@ namespace Emby.Server.Implementations.Library
try
{
Directory.Delete(path, true);
+ _directoryService.Invalidate(path);
}
finally
{
@@ -3929,7 +3998,7 @@ namespace Emby.Server.Implementations.Library
{
await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
- StartScanInBackground();
+ _ = StartScanInBackground();
}
else
{
@@ -3989,6 +4058,7 @@ namespace Emby.Server.Implementations.Library
if (!string.IsNullOrEmpty(shortcut))
{
_fileSystem.DeleteFile(shortcut);
+ _directoryService.Invalidate(shortcut);
}
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
@@ -4032,6 +4102,7 @@ namespace Emby.Server.Implementations.Library
}
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
+ _directoryService.Invalidate(lnk);
RemoveContentTypeOverrides(path);
}
@@ -4071,6 +4142,18 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
+ public IReadOnlyList<string> GetTagNames(InternalItemsQuery query)
+ {
+ if (query.User is not null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return _itemRepository.GetTagNames(query);
+ }
+
+ /// <inheritdoc />
public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType)
{
return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType);
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 97e00177b6..e9bba05839 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -384,7 +384,13 @@ namespace Emby.Server.Implementations.Library
{
ArgumentNullException.ThrowIfNull(item);
- var hasMediaSources = (IHasMediaSources)item;
+ // Clients can ask for the sources of an item that has none (a container queued by mistake).
+ if (item is not IHasMediaSources hasMediaSources)
+ {
+ throw new ArgumentException(
+ string.Format(CultureInfo.InvariantCulture, "{0} {1} has no media sources and cannot be played.", item.GetType().Name, item.Id),
+ nameof(item));
+ }
var sources = hasMediaSources.GetMediaSources(enablePathSubstitution);
@@ -494,7 +500,12 @@ namespace Emby.Server.Implementations.Library
{
var index = userData.SubtitleStreamIndex.Value;
// Make sure the saved index is still valid
- if (index == -1 || source.MediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && i.Index == index))
+ var savedStream = source.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
+ // "Only forced" rules out full tracks entirely, so a remembered one must not resurrect them.
+ // The client reports whatever is playing, so an index remembered under another mode sticks forever otherwise.
+ if (index == -1
+ || (savedStream is not null
+ && (user.SubtitleMode != SubtitlePlaybackMode.OnlyForced || savedStream.IsForced)))
{
source.DefaultSubtitleStreamIndex = index;
return;
diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs
index 306a8673d5..a8ee416b31 100644
--- a/Emby.Server.Implementations/Library/Search/SearchManager.cs
+++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs
@@ -143,11 +143,19 @@ public class SearchManager : ISearchManager
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
- var allowedIds = await baseQuery
- .Select(e => e.Id)
- .ToHashSetAsync(cancellationToken)
+ var allowed = await baseQuery
+ .Select(e => new { e.Id, e.PrimaryVersionId })
+ .ToListAsync(cancellationToken)
.ConfigureAwait(false);
+ var allowedIds = allowed.Select(e => e.Id).ToHashSet();
+
+ // A provider can return both an alternate version and the primary it belongs to, and the
+ // two are one item to the user.
+ allowedIds.ExceptWith(allowed
+ .Where(e => e.PrimaryVersionId.HasValue && allowedIds.Contains(e.PrimaryVersionId.Value))
+ .Select(e => e.Id));
+
if (allowedIds.Count == candidates.Count)
{
return candidates;
diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
index c4d3b249d5..2cbfb6a4fa 100644
--- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
+++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
@@ -115,6 +115,7 @@ public class SqlSearchProvider : IInternalSearchProvider
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
+ dbQuery = ExcludeVersionsOfMatchedPrimaries(dbQuery);
// 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
@@ -193,6 +194,12 @@ public class SqlSearchProvider : IInternalSearchProvider
return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
}
+ private static IQueryable<BaseItemEntity> ExcludeVersionsOfMatchedPrimaries(IQueryable<BaseItemEntity> query)
+ {
+ var matched = query;
+ return query.Where(e => e.PrimaryVersionId == null || !matched.Any(p => p.Id == e.PrimaryVersionId));
+ }
+
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
JellyfinDbContext dbContext,
IQueryable<BaseItemEntity> query,
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
index 57d1f7c770..cc8f0fd24e 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -1,3 +1,5 @@
+#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees.
+
using System;
using System.Collections.Generic;
using System.Linq;
@@ -172,7 +174,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
var allCandidateIdsList = allCandidateIds.ToList();
var accessibleItems = await baseQuery
.WhereOneOrMany(allCandidateIdsList, e => e.Id)
- .Select(e => new { e.Id, e.PresentationUniqueKey })
+ .Select(e => new { e.Id, e.PresentationUniqueKey, e.PrimaryVersionId })
.ToListAsync(cancellationToken).ConfigureAwait(false);
// Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
@@ -189,6 +191,9 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
var orderedIds = accessibleItems
.Where(x => scores.ContainsKey(x.Id))
.OrderByDescending(x => scores.GetValueOrDefault(x.Id))
+ // Two versions of one movie score the same, so name the primary as the
+ // representative of the group rather than whichever came back first.
+ .ThenBy(x => x.PrimaryVersionId.HasValue)
.DistinctBy(x => x.PresentationUniqueKey)
.Take(limit)
.Select(x => x.Id)
@@ -245,6 +250,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
result[id] = [];
}
+ var hiddenVersionIds = context.BaseItems.AsNoTracking()
+ .Where(e => e.PrimaryVersionId != null
+ && context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId))
+ .Select(e => e.Id);
+
foreach (var (valueType, weight) in _itemValueDimensions)
{
var sourceRows = await context.ItemValuesMap.AsNoTracking()
@@ -260,7 +270,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 => !hiddenVersionIds.Contains(m.ItemId) && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
.ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -276,6 +286,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
if (personSourceRows.Count > 0)
{
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
+ .Where(m => !hiddenVersionIds.Contains(m.ItemId))
.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/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
index fd5f292ebe..a18a17b593 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
@@ -651,7 +651,13 @@ public class SimilarItemsManager : ISimilarItemsManager
try
{
- var stream = File.OpenRead(cachePath);
+ var stream = new FileStream(
+ cachePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite | FileShare.Delete,
+ IODefaults.FileStreamBufferSize,
+ FileOptions.Asynchronous | FileOptions.SequentialScan);
await using (stream.ConfigureAwait(false))
{
var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
@@ -675,6 +681,7 @@ public class SimilarItemsManager : ISimilarItemsManager
private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken)
{
+ string? tempPath = null;
try
{
var directory = Path.GetDirectoryName(cachePath);
@@ -689,16 +696,34 @@ public class SimilarItemsManager : ISimilarItemsManager
ExpiresAt = DateTime.UtcNow.Add(cacheDuration)
};
- var stream = File.Create(cachePath);
+ tempPath = cachePath + "." + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + ".tmp";
+ var stream = File.Create(tempPath);
await using (stream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
}
+
+ File.Move(tempPath, cachePath, true);
+ tempPath = null;
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath);
}
+ finally
+ {
+ if (tempPath is not null)
+ {
+ try
+ {
+ File.Delete(tempPath);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogDebug(ex, "Failed to delete temporary similar items cache file {TempPath}", tempPath);
+ }
+ }
+ }
}
private sealed class SimilarItemsCache
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index 47b3891901..cfb2dd53d3 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -60,17 +60,10 @@ namespace Emby.Server.Implementations.Library
var folderViewType = collectionFolder?.CollectionType;
// Playlist and BoxSet libraries require special handling because the folder only references linked items
- if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
+ if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
+ && !HasVisibleChild(folder, user))
{
- var items = folder.GetItemList(new InternalItemsQuery(user)
- {
- ParentId = folder.ParentId
- });
-
- if (!items.Any(item => item.IsVisible(user)))
- {
- continue;
- }
+ continue;
}
if (UserView.IsUserSpecific(folder))
@@ -127,7 +120,7 @@ namespace Emby.Server.Implementations.Library
list.AddRange(channels);
- if (_liveTvManager.GetEnabledUsers().Select(i => i.Id).Contains(user.Id))
+ if (_liveTvManager.IsEnabledForUser(user))
{
list.Add(_liveTvManager.GetInternalLiveTvFolder(CancellationToken.None));
}
@@ -159,6 +152,32 @@ namespace Emby.Server.Implementations.Library
.ToArray();
}
+ private bool HasVisibleChild(Folder folder, User user)
+ {
+ // Folder.Children answers this too, but a collection folder delegates it to its physical
+ // folders, which resolve and then hold on to every child with every field.
+ var parentIds = folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0
+ ? collectionFolder.PhysicalFolderIds
+ : [folder.Id];
+
+ foreach (var parentId in parentIds)
+ {
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ ParentId = parentId,
+ GroupByPresentationUniqueKey = false,
+ DtoOptions = DtoOptions.StoredColumnsOnly
+ });
+
+ if (items.Any(item => item.IsVisible(user)))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName)
{
var uniqueId = parentId + "subview" + type;
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/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
index 078a0b921d..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,112 +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)
{
// 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.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
+ _logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned);
}
- var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
-
- var numComplete = 0;
-
- 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, deleted {Orphaned} orphaned credits", numOrphaned);
+ return (newNames, deadIds);
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json
index 49ebc45f06..c5b1213096 100644
--- a/Emby.Server.Implementations/Localization/Core/be.json
+++ b/Emby.Server.Implementations/Localization/Core/be.json
@@ -112,5 +112,11 @@
"NameExtraInterview": "Інтэрв'ю",
"NameExtraNumbered": "{0} {1}",
"NameExtraScene": "Сцэна",
- "NameExtraTrailer": "Трэйлер"
+ "NameExtraTrailer": "Трэйлер",
+ "NameExtraBehindTheScenes": "За кулісамі",
+ "NameExtraClip": "Кліп",
+ "NameExtraFeaturette": "Кароткаметражка",
+ "NameExtraSample": "Прыклад",
+ "NameExtraShort": "Кароткаметражка",
+ "NameExtraThemeSong": "Тэматычная песня"
}
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/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json
index 033002d2b2..cfc16d553c 100644
--- a/Emby.Server.Implementations/Localization/Core/cs.json
+++ b/Emby.Server.Implementations/Localization/Core/cs.json
@@ -1,6 +1,6 @@
{
"AppDeviceValues": "Aplikace: {0}, Zařízení: {1}",
- "Artists": "Umělci",
+ "Artists": "Interpreti",
"AuthenticationSucceededWithUserName": "{0} úspěšně ověřen",
"Books": "Knihy",
"ChapterNameValue": "Kapitola {0}",
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 6e5532ead9..4c7d16a9a7 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -10,7 +10,7 @@
"Folders": "Mapper",
"Genres": "Genrer",
"HeaderContinueWatching": "Fortsæt med at se",
- "HeaderFavoriteEpisodes": "Yndlingsafsnit",
+ "HeaderFavoriteEpisodes": "Favoritepisoder",
"HeaderFavoriteShows": "Yndlingsserier",
"HeaderLiveTV": "Live-TV",
"HeaderNextUp": "Næste",
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index c0ad2c165a..ee03c471ea 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
- "Original": "Πρωτότυπο"
+ "Original": "Πρωτότυπο",
+ "NameExtraBehindTheScenes": "Πίσω από τις Σκηνές",
+ "NameExtraDeletedScene": "Διεγραμμένη Σκηνή",
+ "NameExtraFeaturette": "Πρόσθετα βίντεο",
+ "NameExtraInterview": "Συνέντευξη",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Δείγμα",
+ "NameExtraScene": "Σκηνή",
+ "NameExtraShort": "Βίντεο μικρού μήκους",
+ "NameExtraThemeSong": "Θεματικό Τραγούδι",
+ "NameExtraThemeVideo": "Θεματικό Βίντεο",
+ "NameExtraTrailer": "τρέιλερ ταινίας",
+ "NameExtraUnknown": "Πρόσθετα",
+ "NameExtraClip": "Απόσπασμα"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index 36a248a1d1..9a453120dd 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -113,5 +113,12 @@
"NameExtraClip": "Klippi",
"NameExtraDeletedScene": "Poistettu Kohtaus",
"NameExtraFeaturette": "Lyhytelokuva",
- "NameExtraInterview": "Haastattelu"
+ "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 1a1c89da35..acb78290f3 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -7,8 +7,8 @@
"AppDeviceValues": "App: {0}, Eind: {1}",
"Books": "Bøkur",
"ChapterNameValue": "Kapittul {0}",
- "Favorites": "Yndis",
- "Folders": "Mappur",
+ "Favorites": "Yndislisti",
+ "Folders": "Skjáttur",
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
@@ -25,7 +25,7 @@
"NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
"NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
"NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
- "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
+ "NotificationOptionPluginUninstalled": "Ískoytisforrit er strikað",
"NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
"NotificationOptionUserLockedOut": "Brúkari útihýstur",
"Photos": "Ljósmyndir",
@@ -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.",
@@ -96,15 +96,15 @@
"TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.",
"TaskRefreshChapterImages": "Kapitlamyndaúttøkur",
"NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað",
- "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað",
+ "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl er steðgað",
"NotificationOptionAudioPlayback": "Ljóðspæl byrjað",
- "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað",
+ "NotificationOptionAudioPlaybackStopped": "Ljóðspæl er 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": "Forfilmur",
+ "NameExtraTrailer": "Brellbiti",
"NameExtraInterview": "Samrøða",
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
"NameExtraClip": "Klipp",
@@ -112,14 +112,14 @@
"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",
- "NameExtraScene": "Mynd (scena)",
+ "NameExtraScene": "Mynd",
"NameExtraUnknown": "Eykatilfar",
"Original": "Upprunalig(t/ur)"
}
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/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 0ca7c6aa08..04877b18a7 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -112,8 +112,14 @@
"NameExtraBehindTheScenes": "מאחורי הקלעים",
"NameExtraClip": "קליפ",
"NameExtraDeletedScene": "סצנה שנמחקה",
- "NameExtraFeaturette": "סרט קצר",
+ "NameExtraFeaturette": "סרט עלילה קצר",
"NameExtraInterview": "ריאיון",
"NameExtraSample": "דגימה",
- "NameExtraScene": "סצנה"
+ "NameExtraScene": "סצנה",
+ "NameExtraThemeSong": "שיר נושא",
+ "NameExtraThemeVideo": "סרטון נושא",
+ "NameExtraTrailer": "קדימון",
+ "NameExtraUnknown": "נוסף",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraShort": "סרט קצר"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index 442c26b30b..c7e2b5f5e9 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -108,5 +108,18 @@
"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",
- "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
+ "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",
+ "NameExtraUnknown": "Dodatno"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index 78b7ec744b..49757d66e7 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -2,7 +2,7 @@
"AppDeviceValues": "アプリ: {0}, デバイス: {1}",
"Artists": "アーティスト",
"AuthenticationSucceededWithUserName": "{0} 認証に成功しました",
- "Books": "ブック",
+ "Books": "書籍",
"ChapterNameValue": "チャプター {0}",
"Collections": "コレクション",
"FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました",
diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json
index 917f26a49c..1e64a130eb 100644
--- a/Emby.Server.Implementations/Localization/Core/lb.json
+++ b/Emby.Server.Implementations/Localization/Core/lb.json
@@ -83,7 +83,7 @@
"NewVersionIsAvailable": "Nei Versioun fir Jellyfin Server ass verfügbar.",
"PluginInstalledWithName": "{0} installéiert",
"TaskMoveTrickplayImagesDescription": "Verschëfft existent Trickplay-Dateien no de Bibliothéik-Astellungen.",
- "AppDeviceValues": "App: {0}, Geräter: {1}",
+ "AppDeviceValues": "App: {0}, Apparater: {1}",
"FailedLoginAttemptWithUserName": "Net Gelongen Umeldung {0}",
"HeaderLiveTV": "LiveTV",
"NotificationOptionServerRestartRequired": "Server Restart Erfuerderlech",
@@ -108,5 +108,17 @@
"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."
+ "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 cc4899ed50..db4710ddca 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -96,18 +96,18 @@
"External": "Išorinis",
"HearingImpaired": "Su klausos sutrikimais",
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
- "TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įgalintose bibliotekose, Trickplay peržiūras.",
+ "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ų žodžius",
+ "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus",
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
- "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
+ "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ėgstamą būseną ir t. t.).",
- "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
+ "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}",
"NameExtraBehindTheScenes": "Užkulisiuose",
"NameExtraClip": "Klipas",
"NameExtraDeletedScene": "Ištrinta scena",
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 76fa9e3cf7..52f1eecbe4 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -108,5 +108,17 @@
"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",
- "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
+ "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/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/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/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index 358c19881f..dccec8067d 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -111,5 +111,14 @@
"Original": "Original",
"NameExtraBehindTheScenes": "În culise",
"NameExtraClip": "Clip",
- "NameExtraDeletedScene": "Scenă ștearsă"
+ "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/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index 9573eeefe4..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 zoznamov prehrávania. 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í",
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
index a1b5b714af..6ea625d66c 100644
--- a/Emby.Server.Implementations/Localization/Core/sl-SI.json
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -108,5 +108,17 @@
"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.",
"LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
- "Original": "Original"
+ "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/th.json b/Emby.Server.Implementations/Localization/Core/th.json
index 716e3ae55d..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": "โฟลเดอร์",
@@ -107,6 +107,19 @@
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
- "LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
- "Original": "ต้นฉบับ"
+ "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 67f4ecc4d5..87f49ae80d 100644
--- a/Emby.Server.Implementations/Localization/Core/tr.json
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -81,8 +81,8 @@
"TaskCleanLogs": "Günlük Dizinini Temizle",
"TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.",
"TaskRefreshLibrary": "Medya Kütüphanesini Tara",
- "TaskRefreshChapterImagesDescription": "Bölümlere ayrılmış videolar için küçük resimler oluştur.",
- "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
+ "TaskRefreshChapterImagesDescription": "Video bölümleri için küçük görseller oluştur.",
+ "TaskRefreshChapterImages": "Bölüm görsellerini çıkar",
"TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.",
"TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle",
"TaskCleanActivityLogDescription": "Yapılandırılan tarihten daha eski olan etkinlik günlüğü girişlerini siler.",
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/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
index 9590d9c1c9..7c61ce6f17 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-CN.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -22,7 +22,7 @@
"MixedContent": "混合内容",
"Movies": "电影",
"Music": "音乐",
- "MusicVideos": "MV",
+ "MusicVideos": "音乐视频",
"NameInstallFailed": "{0} 安装失败",
"NameSeasonNumber": "第 {0} 季",
"NameSeasonUnknown": "未知季",
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 0331ec39e5..3f89237ab2 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -139,7 +139,8 @@ namespace Emby.Server.Implementations.Localization
var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions).ConfigureAwait(false)
?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
- var dict = new Dictionary<string, ParentalRatingScore?>();
+ // Rating strings are compared case insensitively, providers are not consistent about casing (e.g. "VM18" vs "vm18")
+ var dict = new Dictionary<string, ParentalRatingScore?>(StringComparer.OrdinalIgnoreCase);
if (ratingSystem.Ratings is not null)
{
foreach (var ratingEntry in ratingSystem.Ratings)
@@ -374,12 +375,37 @@ namespace Emby.Server.Implementations.Localization
{
ArgumentException.ThrowIfNullOrEmpty(rating);
+ // Handle unrated content. This has to happen before the split below,
+ // because some of the unrated values contain a '/' themselves (e.g. "n/a").
+ if (IsUnrated(rating))
+ {
+ return null;
+ }
+
+ // Several rating systems contain a '/' inside a single rating (e.g. "M/12" in PT,
+ // "U/A 13+" in IN, "7/i/fig" in ES), so the value as a whole always wins over the split below.
+ var wholeValueScore = GetSingleRatingScore(rating, countryCode);
+ if (wholeValueScore is not null)
+ {
+ return wholeValueScore;
+ }
+
// 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);
+ if (ratingValues.Length == 1)
+ {
+ return null;
+ }
foreach (var ratingValue in ratingValues)
{
+ // A single entry of such a list may be unrated while a later one still resolves
+ if (IsUnrated(ratingValue))
+ {
+ continue;
+ }
+
var score = GetSingleRatingScore(ratingValue, countryCode);
if (score is not null)
{
@@ -391,16 +417,18 @@ namespace Emby.Server.Implementations.Localization
}
/// <summary>
+ /// Checks whether a rating value marks the content as unrated.
+ /// </summary>
+ /// <param name="rating">Rating value to check.</param>
+ /// <returns>Returns true if the value is an unrated marker.</returns>
+ private static bool IsUnrated(ReadOnlySpan<char> rating)
+ => _unratedValues.Contains(rating.Trim(), StringComparison.OrdinalIgnoreCase);
+
+ /// <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))
- {
- return null;
- }
-
// Convert ints directly
// This may override some of the locale specific age ratings (but those always map to the same age)
if (TryParseRatingAsScore(rating, out var ratingAge))
@@ -512,6 +540,12 @@ namespace Emby.Server.Implementations.Localization
return true;
}
+ // Explicitly unrated content (e.g. "IT-NR") is unrated by definition, not a lookup failure
+ if (IsUnrated(ratingPart))
+ {
+ return true;
+ }
+
_logger.LogWarning(
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
rating,
diff --git a/Emby.Server.Implementations/Localization/Ratings/it.json b/Emby.Server.Implementations/Localization/Ratings/it.json
index f2889bf82c..c1d716a46c 100644
--- a/Emby.Server.Implementations/Localization/Ratings/it.json
+++ b/Emby.Server.Implementations/Localization/Ratings/it.json
@@ -3,28 +3,42 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["T"],
+ "ratingStrings": ["T", "PT", "Per tutti"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["6+"],
+ "ratingStrings": ["6+", "VM6", "VM 6", "VM-6", "Vietato ai minori di 6 anni"],
"ratingScore": {
"score": 6,
"subScore": null
}
},
{
- "ratingStrings": ["14+"],
+ "ratingStrings": ["10+", "VM10", "VM 10", "VM-10", "Vietato ai minori di 10 anni"],
+ "ratingScore": {
+ "score": 10,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["12+", "VM12", "VM 12", "VM-12", "Vietato ai minori di 12 anni"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["14+", "VM14", "VM 14", "VM-14", "Vietato ai minori di 14 anni"],
"ratingScore": {
"score": 14,
"subScore": null
}
},
{
- "ratingStrings": ["18+"],
+ "ratingStrings": ["18+", "VM18", "VM 18", "VM-18", "Vietato ai minori di 18 anni"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/no.json b/Emby.Server.Implementations/Localization/Ratings/no.json
index a5e9523163..40c66e3956 100644
--- a/Emby.Server.Implementations/Localization/Ratings/no.json
+++ b/Emby.Server.Implementations/Localization/Ratings/no.json
@@ -10,49 +10,49 @@
}
},
{
- "ratingStrings": ["6"],
+ "ratingStrings": ["6", "6 år"],
"ratingScore": {
"score": 6,
"subScore": null
}
},
{
- "ratingStrings": ["7"],
+ "ratingStrings": ["7", "7 år"],
"ratingScore": {
"score": 7,
"subScore": null
}
},
{
- "ratingStrings": ["9"],
+ "ratingStrings": ["9", "9 år"],
"ratingScore": {
"score": 9,
"subScore": null
}
},
{
- "ratingStrings": ["11"],
+ "ratingStrings": ["11", "11 år"],
"ratingScore": {
"score": 11,
"subScore": null
}
},
{
- "ratingStrings": ["12"],
+ "ratingStrings": ["12", "12 år"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
- "ratingStrings": ["15"],
+ "ratingStrings": ["15", "15 år"],
"ratingScore": {
"score": 15,
"subScore": null
}
},
{
- "ratingStrings": ["18"],
+ "ratingStrings": ["18", "18 år"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
index 308faed8cc..8208c85222 100644
--- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs
+++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
@@ -371,7 +371,7 @@ namespace Emby.Server.Implementations.Playlists
if (extension.Equals(".wpl", StringComparison.OrdinalIgnoreCase))
{
var playlist = new WplPlaylist();
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new WplPlaylistEntry()
{
@@ -404,7 +404,7 @@ namespace Emby.Server.Implementations.Playlists
else if (extension.Equals(".zpl", StringComparison.OrdinalIgnoreCase))
{
var playlist = new ZplPlaylist();
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new ZplPlaylistEntry()
{
@@ -440,7 +440,7 @@ namespace Emby.Server.Implementations.Playlists
{
IsExtended = true
};
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new M3uPlaylistEntry()
{
@@ -472,7 +472,7 @@ namespace Emby.Server.Implementations.Playlists
IsExtended = true
};
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new M3uPlaylistEntry()
{
@@ -500,7 +500,7 @@ namespace Emby.Server.Implementations.Playlists
else if (extension.Equals(".pls", StringComparison.OrdinalIgnoreCase))
{
var playlist = new PlsPlaylist();
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new PlsPlaylistEntry()
{
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/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
index 8d133dc074..687947616f 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
@@ -11,7 +11,7 @@ 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
{
@@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
return;
}
- _logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
+ _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 42835d7ad0..092a621bfc 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
@@ -4,6 +4,7 @@ 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;
@@ -29,6 +30,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILogger<PeopleValidationTask> _logger;
+ private readonly ILogger<PeopleValidator> _validatorLogger;
private readonly IItemTypeLookup _itemTypeLookup;
/// <summary>
@@ -39,6 +41,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
/// <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,
@@ -46,6 +49,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
IDbContextFactory<JellyfinDbContext> dbContextFactory,
IFileSystem fileSystem,
ILogger<PeopleValidationTask> logger,
+ ILogger<PeopleValidator> validatorLogger,
IItemTypeLookup itemTypeLookup)
{
_libraryManager = libraryManager;
@@ -53,6 +57,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_logger = logger;
+ _validatorLogger = validatorLogger;
_itemTypeLookup = itemTypeLookup;
}
@@ -165,7 +170,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
// Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
// cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
- await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
+ 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));
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 94215bed79..08e2578867 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -1449,6 +1449,8 @@ namespace Emby.Server.Implementations.Session
if (item is IItemByName byName)
{
+ // A by-name item tags containers as well as leaves: a music genre tags its artists,
+ // and a by-name artist row is not a folder, so IsFolder does not exclude it here.
return byName.GetTaggedItems(new InternalItemsQuery(user)
{
IsFolder = false,
@@ -1463,7 +1465,7 @@ namespace Emby.Server.Implementations.Session
},
IsVirtualItem = false,
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
- });
+ }).Where(i => i is not IItemByName);
}
if (item.IsFolder)
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index e81edc82c6..2100c23c45 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -171,7 +171,7 @@ namespace Emby.Server.Implementations.Session
{
await SendForceKeepAlive(webSocket).ConfigureAwait(false);
}
- catch (WebSocketException exception)
+ catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or OperationCanceledException)
{
_logger.LogWarning(exception, "Cannot send ForceKeepAlive message to WebSocket {0}.", webSocket);
}
@@ -232,7 +232,7 @@ namespace Emby.Server.Implementations.Session
{
await SendForceKeepAlive(webSocket).ConfigureAwait(false);
}
- catch (WebSocketException exception)
+ catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or OperationCanceledException)
{
_logger.LogInformation(exception, "Error sending ForceKeepAlive message to WebSocket.");
lost.Add(webSocket);
diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs
index 38a0018a70..6fbe46ffd6 100644
--- a/Emby.Server.Implementations/SyncPlay/Group.cs
+++ b/Emby.Server.Implementations/SyncPlay/Group.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Controller.SyncPlay;
using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
using MediaBrowser.Controller.SyncPlay.Queue;
using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
@@ -27,6 +28,11 @@ namespace Emby.Server.Implementations.SyncPlay
public class Group : IGroupStateContext
{
/// <summary>
+ /// The default value of <see cref="GroupWaitTimeout"/>, in milliseconds.
+ /// </summary>
+ internal const long DefaultGroupWaitTimeout = 30000;
+
+ /// <summary>
/// The logger.
/// </summary>
private readonly ILogger<Group> _logger;
@@ -54,8 +60,12 @@ namespace Emby.Server.Implementations.SyncPlay
/// <summary>
/// The participants, or members of the group.
/// </summary>
- private readonly Dictionary<string, GroupMember> _participants =
- new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary<string, GroupMember> _participants = new(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// The sessions of the participants, which only carry identifiers.
+ /// </summary>
+ private readonly Dictionary<string, SessionInfo> _participantSessions = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The internal group state.
@@ -91,6 +101,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>
@@ -103,6 +125,19 @@ namespace Emby.Server.Implementations.SyncPlay
public long MaxPlaybackOffset { get; } = 500;
/// <summary>
+ /// Gets the maximum time, in milliseconds, the group waits for its members to report ready.
+ /// </summary>
+ /// <value>The group-wait timeout.</value>
+ internal long GroupWaitTimeout { get; init; } = DefaultGroupWaitTimeout;
+
+ /// <summary>
+ /// Gets the <see cref="Environment.TickCount64"/> value at which the group gives up waiting
+ /// for its members, or <c>null</c> when it is not waiting for anyone.
+ /// </summary>
+ /// <value>The group-wait deadline.</value>
+ internal long? GroupWaitDeadline { get; private set; }
+
+ /// <summary>
/// Gets the group identifier.
/// </summary>
/// <value>The group identifier.</value>
@@ -151,6 +186,8 @@ namespace Emby.Server.Implementations.SyncPlay
Ping = DefaultPing,
IsBuffering = false
});
+
+ _participantSessions[session.Id] = session;
}
/// <summary>
@@ -160,6 +197,8 @@ namespace Emby.Server.Implementations.SyncPlay
private void RemoveSession(SessionInfo session)
{
_participants.Remove(session.Id);
+ _participantSessions.Remove(session.Id);
+ UpdateGroupWaitDeadline(false);
}
/// <summary>
@@ -377,13 +416,20 @@ namespace Emby.Server.Implementations.SyncPlay
{
value.IgnoreGroupWait = ignoreGroupWait;
}
+
+ UpdateGroupWaitDeadline(false);
}
/// <inheritdoc />
public void SetState(IGroupState state)
{
_logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type);
- this._state = state;
+ _state = state;
+
+ if (state.Type != GroupStateType.Waiting)
+ {
+ GroupWaitDeadline = null;
+ }
}
/// <inheritdoc />
@@ -438,7 +484,7 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (_participants.TryGetValue(session.Id, out GroupMember value))
{
- value.Ping = ping;
+ value.Ping = Math.Clamp(ping, 0, MaxPing);
}
}
@@ -451,7 +497,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 />
@@ -461,6 +509,8 @@ namespace Emby.Server.Implementations.SyncPlay
{
value.IsBuffering = isBuffering;
}
+
+ UpdateGroupWaitDeadline(false);
}
/// <inheritdoc />
@@ -470,6 +520,9 @@ namespace Emby.Server.Implementations.SyncPlay
{
session.IsBuffering = isBuffering;
}
+
+ // Resetting the status of every session starts a new waiting period.
+ UpdateGroupWaitDeadline(isBuffering);
}
/// <inheritdoc />
@@ -676,5 +729,85 @@ namespace Emby.Server.Implementations.SyncPlay
PlayQueue.ShuffleMode,
PlayQueue.RepeatMode);
}
+
+ /// <summary>
+ /// Stops waiting for the members that have not reported ready and lets the rest of the
+ /// group carry on. Does nothing until <see cref="GroupWaitDeadline"/> has passed.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ internal void HandleGroupWaitTimeout(CancellationToken cancellationToken)
+ {
+ var deadline = GroupWaitDeadline;
+ if (deadline is null || deadline > Environment.TickCount64)
+ {
+ return;
+ }
+
+ GroupWaitDeadline = null;
+
+ if (_state is not WaitingGroupState waitingState)
+ {
+ return;
+ }
+
+ var blockingSessions = _participantSessions
+ .Values
+ .Where(participant => _participants.TryGetValue(participant.Id, out var member)
+ && member.IsBuffering
+ && !member.IgnoreGroupWait)
+ .ToList();
+
+ if (blockingSessions.Count == 0)
+ {
+ return;
+ }
+
+ // The recovery below is broadcast to the whole group, so it does not matter which of
+ // the sessions that kept the group waiting is the one acting on the group's behalf.
+ var session = blockingSessions[0];
+
+ _logger.LogWarning(
+ "Group {GroupId} waited {Waited} ms for session(s) {SessionIds} to report ready, giving up.",
+ GroupId.ToString(),
+ GroupWaitTimeout + Environment.TickCount64 - deadline.Value,
+ string.Join(", ", blockingSessions.Select(participant => participant.Id)));
+
+ if (waitingState.ResumePlaying)
+ {
+ // An unpause request in the waiting state means "start now, ignoring the sessions
+ // that are not ready".
+ var unpauseRequest = new UnpauseGroupRequest();
+ waitingState.HandleRequest(unpauseRequest, this, GroupStateType.Waiting, session, cancellationToken);
+ return;
+ }
+
+ // The members have been paused for the whole waiting period, so the playback position
+ // stays where the wait started.
+ SetAllBuffering(false);
+ SetState(new PausedGroupState(_loggerFactory));
+
+ var command = NewSyncPlayCommand(SendCommandType.Pause);
+ SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ var stateUpdate = new GroupStateUpdate(GroupStateType.Paused, PlaybackRequestType.Pause);
+ var update = new SyncPlayStateUpdate(GroupId, stateUpdate);
+ SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ private void UpdateGroupWaitDeadline(bool startNewWaitingPeriod)
+ {
+ if (_state.Type != GroupStateType.Waiting || !IsBuffering())
+ {
+ GroupWaitDeadline = null;
+ return;
+ }
+
+ // A running deadline covers the waiting period as a whole, so the sessions that keep
+ // reporting buffering while they load must not push it back.
+ if (GroupWaitDeadline is null || startNewWaitingPeriod)
+ {
+ GroupWaitDeadline = Environment.TickCount64 + GroupWaitTimeout;
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
index b45d754554..88dfb070b8 100644
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
+++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
@@ -19,6 +19,11 @@ namespace Emby.Server.Implementations.SyncPlay
public class SyncPlayManager : ISyncPlayManager, IDisposable
{
/// <summary>
+ /// How often, in milliseconds, the groups are checked for a spent wait deadline.
+ /// </summary>
+ private const int GroupWaitSweepInterval = 1000;
+
+ /// <summary>
/// The logger.
/// </summary>
private readonly ILogger<SyncPlayManager> _logger;
@@ -69,6 +74,11 @@ namespace Emby.Server.Implementations.SyncPlay
/// </remarks>
private readonly Lock _groupsLock = new();
+ /// <summary>
+ /// The timer that watches the groups' wait deadlines, running only while there are groups.
+ /// </summary>
+ private readonly Timer _groupWaitTimer;
+
private bool _disposed = false;
/// <summary>
@@ -90,8 +100,15 @@ namespace Emby.Server.Implementations.SyncPlay
_libraryManager = libraryManager;
_logger = loggerFactory.CreateLogger<SyncPlayManager>();
_sessionManager.SessionEnded += OnSessionEnded;
+ _groupWaitTimer = new Timer(_ => OnGroupWaitTimerTick(), null, Timeout.Infinite, Timeout.Infinite);
}
+ /// <summary>
+ /// Gets the maximum time, in milliseconds, a group waits for its members to report ready.
+ /// </summary>
+ /// <value>The group-wait timeout.</value>
+ internal long GroupWaitTimeout { get; init; } = Group.DefaultGroupWaitTimeout;
+
/// <inheritdoc />
public void Dispose()
{
@@ -122,8 +139,12 @@ namespace Emby.Server.Implementations.SyncPlay
LeaveGroup(session, leaveGroupRequest, cancellationToken);
}
- var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager);
+ var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager)
+ {
+ GroupWaitTimeout = GroupWaitTimeout
+ };
_groups[group.GroupId] = group;
+ UpdateGroupWaitTimer();
if (!_sessionToGroupMap.TryAdd(session.Id, group))
{
@@ -181,8 +202,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;
}
@@ -242,6 +263,7 @@ namespace Emby.Server.Implementations.SyncPlay
{
_logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId);
_groups.Remove(group.GroupId, out _);
+ UpdateGroupWaitTimer();
}
}
}
@@ -332,8 +354,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;
@@ -381,7 +406,50 @@ namespace Emby.Server.Implementations.SyncPlay
}
_sessionManager.SessionEnded -= OnSessionEnded;
- _disposed = true;
+
+ lock (_groupsLock)
+ {
+ _disposed = true;
+ _groupWaitTimer.Dispose();
+ }
+ }
+
+ private void UpdateGroupWaitTimer()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ var interval = _groups.IsEmpty ? Timeout.Infinite : GroupWaitSweepInterval;
+ _groupWaitTimer.Change(interval, interval);
+ }
+
+ private void OnGroupWaitTimerTick()
+ {
+ try
+ {
+ lock (_groupsLock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ foreach (var (_, group) in _groups)
+ {
+ // Group lock required as Group is not thread-safe.
+ lock (group)
+ {
+ group.HandleGroupWaitTimeout(CancellationToken.None);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while recovering SyncPlay groups from a timed out wait.");
+ }
}
private void OnSessionEnded(object sender, SessionEventArgs e)
@@ -400,7 +468,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/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();