aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/Chapters/ChapterManager.cs9
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs7
-rw-r--r--Emby.Server.Implementations/Data/ItemTypeLookup.cs2
-rw-r--r--Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs35
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs105
-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.cs32
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs13
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs27
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs94
-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.cs16
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs29
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs37
-rw-r--r--Emby.Server.Implementations/Localization/Core/bs.json5
-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/fo.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json33
-rw-r--r--Emby.Server.Implementations/Localization/Core/lb.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json2
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs90
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/0-prefer.json2
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ar.json24
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/au.json2
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/de.json10
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/fr.json8
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/id.json25
-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/Localization/Ratings/tw.json10
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs10
-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.cs125
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs69
43 files changed, 833 insertions, 162 deletions
diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs
index 69cbe533c6..2938170d1b 100644
--- a/Emby.Server.Implementations/Chapters/ChapterManager.cs
+++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs
@@ -137,6 +137,7 @@ public class ChapterManager : IChapterManager
var success = true;
var changesMade = false;
+ var extractedCount = 0;
var runtimeTicks = video.RunTimeTicks ?? 0;
@@ -178,7 +179,7 @@ public class ChapterManager : IChapterManager
Protocol = video.PathProtocol ?? MediaProtocol.File,
};
- _logger.LogInformation("Extracting chapter image for {Name} at {Path}", video.Name, inputPath);
+ _logger.LogDebug("Extracting chapter image for {Name} at {Time}", video.Name, time);
var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
File.Copy(tempFile, path, true);
@@ -194,6 +195,7 @@ public class ChapterManager : IChapterManager
chapter.ImagePath = path;
chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
changesMade = true;
+ extractedCount++;
}
catch (Exception ex)
{
@@ -222,6 +224,11 @@ public class ChapterManager : IChapterManager
}
}
+ if (extractedCount > 0)
+ {
+ _logger.LogInformation("Extracted {Count} chapter images for {Name} at {Path}", extractedCount, video.Name, video.Path);
+ }
+
if (saveChapters && changesMade)
{
SaveChapters(video, chapters);
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/Data/ItemTypeLookup.cs b/Emby.Server.Implementations/Data/ItemTypeLookup.cs
index 82c0a8b6c5..03117f6de5 100644
--- a/Emby.Server.Implementations/Data/ItemTypeLookup.cs
+++ b/Emby.Server.Implementations/Data/ItemTypeLookup.cs
@@ -1,8 +1,8 @@
using System.Collections.Frozen;
using System.Collections.Generic;
-using System.Threading.Channels;
using Emby.Server.Implementations.Playlists;
using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
diff --git a/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs b/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs
new file mode 100644
index 0000000000..be1fe660c9
--- /dev/null
+++ b/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using MediaBrowser.Controller.Library;
+
+namespace Emby.Server.Implementations.Data;
+
+/// <summary>
+/// Refreshes the database statistics after every library scan.
+/// </summary>
+/// <remarks>
+/// The scheduled optimization skips itself while a scan runs, so without this the first scan of a new server
+/// leaves every query planned for an empty library until the next scheduled run.
+/// </remarks>
+public class RefreshDatabaseStatisticsPostScanTask : ILibraryPostScanTask
+{
+ private readonly IJellyfinDatabaseProvider _databaseProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshDatabaseStatisticsPostScanTask"/> class.
+ /// </summary>
+ /// <param name="databaseProvider">The database provider.</param>
+ public RefreshDatabaseStatisticsPostScanTask(IJellyfinDatabaseProvider databaseProvider)
+ {
+ _databaseProvider = databaseProvider;
+ }
+
+ /// <inheritdoc />
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ await _databaseProvider.RefreshStatistics(cancellationToken).ConfigureAwait(false);
+ progress.Report(100);
+ }
+}
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 59e75691dc..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;
@@ -192,15 +193,11 @@ namespace Emby.Server.Implementations.Dto
itemCountsBatch = GetItemCountsBatch(accessibleItems, user);
}
- // Batch-fetch child counts for all folders to avoid N+1 queries
+ // 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
@@ -714,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 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)
{
- return Random.Shared.Next(1, 10);
+ 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/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 0f92e2f03e..e51c863f86 100644
--- a/Emby.Server.Implementations/IO/LibraryMonitor.cs
+++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs
@@ -3,6 +3,7 @@ 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;
@@ -40,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>
@@ -69,7 +76,7 @@ namespace Emby.Server.Implementations.IO
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
appLifetime.ApplicationStarted.Register(Start);
- appLifetime.ApplicationStopping.Register(Stop);
+ appLifetime.ApplicationStopping.Register(Dispose);
}
/// <inheritdoc />
@@ -120,6 +127,11 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc />
public void Start()
{
+ if (_disposed)
+ {
+ return;
+ }
+
_libraryManager.ItemAdded += OnLibraryManagerItemAdded;
_libraryManager.ItemRemoved += OnLibraryManagerItemRemoved;
@@ -233,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(() =>
{
@@ -256,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);
@@ -357,6 +375,11 @@ namespace Emby.Server.Implementations.IO
{
ArgumentException.ThrowIfNullOrEmpty(path);
+ if (_disposed)
+ {
+ return;
+ }
+
if (IgnorePatterns.ShouldIgnore(path))
{
return;
@@ -452,6 +475,8 @@ namespace Emby.Server.Implementations.IO
/// </summary>
public void Stop()
{
+ Interlocked.Increment(ref _watcherGeneration);
+
_libraryManager.ItemAdded -= OnLibraryManagerItemAdded;
_libraryManager.ItemRemoved -= OnLibraryManagerItemRemoved;
@@ -496,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/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index 7cae2a671b..1d3cc3d383 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
@@ -12,6 +13,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
@@ -20,8 +22,11 @@ namespace Emby.Server.Implementations.Images
{
public class CollectionFolderImageProvider : BaseDynamicImageProvider<CollectionFolder>
{
- public CollectionFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ private readonly ILibraryManager _libraryManager;
+
+ public CollectionFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
{
+ _libraryManager = libraryManager;
}
protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item)
@@ -33,8 +38,11 @@ namespace Emby.Server.Implementations.Images
if (viewType == CollectionType.music)
{
- // Music albums usually don't have dedicated backdrops, so use artist instead
- includeItemTypes = [BaseItemKind.MusicArtist];
+ // Music albums usually don't have dedicated backdrops, so use artist instead.
+ // Artists carry no library of their own, so an item query for them is not
+ // restricted to this library and would collage the artists of every music
+ // library. Resolve them through the tracks that credit them instead.
+ return GetArtistsWithImages(view);
}
return view.GetItemList(new InternalItemsQuery
@@ -49,6 +57,19 @@ namespace Emby.Server.Implementations.Images
});
}
+ private IReadOnlyList<BaseItem> GetArtistsWithImages(CollectionFolder view)
+ {
+ return _libraryManager.GetAllArtists(new InternalItemsQuery
+ {
+ AncestorIds = [view.Id],
+ DtoOptions = new DtoOptions(false),
+ EnableTotalRecordCount = false,
+ ImageTypes = [ImageType.Primary],
+ Limit = 8,
+ OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
+ }).Items.Select(i => i.Item).ToArray();
+ }
+
protected override bool Supports(BaseItem item)
{
return item is CollectionFolder;
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 0044fcd4dc..6f3df084c0 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -318,7 +318,7 @@ namespace Emby.Server.Implementations.Library
if (wizardChanged)
{
- _taskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>();
+ QueueLibraryScan();
}
}
@@ -878,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;
+ }
}
}
@@ -1343,7 +1354,6 @@ namespace Emby.Server.Implementations.Library
{
IncludeItemTypes = [BaseItemKind.MusicArtist],
Name = name,
- UseRawName = true,
DtoOptions = options
}).Cast<MusicArtist>()
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
@@ -3456,6 +3466,7 @@ namespace Emby.Server.Implementations.Library
var extras = new List<BaseItem>();
var typeCounters = new Dictionary<ExtraType, int>();
+ var generatedNames = new Dictionary<ExtraType, HashSet<string>>();
// Order by path so that the numbering handed out below does not depend on the
// order the file system happened to list the folder in
@@ -3492,10 +3503,12 @@ namespace Emby.Server.Implementations.Library
extra = itemById;
}
- // An extra is named after its file, so the file is the source of truth. Items created
- // by older versions, or renamed by a metadata provider, are corrected here;
- // RefreshExtras persists the change.
- if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true)
+ // The name derived from the file is only a default. A name that came from anywhere else,
+ // such as a local metadata file, is the user's and has to survive the scan, so only a
+ // name this method handed out itself is renewed; RefreshExtras persists the change.
+ if (!string.IsNullOrEmpty(name)
+ && extra.LockedFields?.Contains(MetadataField.Name) != true
+ && (itemById is null || IsGeneratedExtraName(extra.Name, candidate)))
{
extra.Name = name;
}
@@ -3517,6 +3530,31 @@ namespace Emby.Server.Implementations.Library
return null;
}
+
+ bool IsGeneratedExtraName(string currentName, ExtraCandidate candidate)
+ {
+ // The file name is what an extra was called before it was given a name of its type
+ if (string.Equals(currentName, candidate.Extra.Name, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (!generatedNames.TryGetValue(candidate.ExtraType, out var names))
+ {
+ // Any of the numbers of this type may have been handed out, as the order the extras
+ // of a type are numbered in shifts as files appear beside them or are taken away
+ names = new HashSet<string>(StringComparer.Ordinal);
+ var count = candidates.Count(c => c.ExtraType == candidate.ExtraType);
+ for (var seen = 0; seen < count; seen++)
+ {
+ names.Add(GetNumberedExtraName(candidate.ExtraType, seen));
+ }
+
+ generatedNames[candidate.ExtraType] = names;
+ }
+
+ return names.Contains(currentName);
+ }
}
/// <summary>
@@ -3543,7 +3581,18 @@ namespace Emby.Server.Implementations.Library
typeCounters.TryGetValue(candidate.ExtraType, out var seen);
typeCounters[candidate.ExtraType] = seen + 1;
- var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType));
+ return GetNumberedExtraName(candidate.ExtraType, seen);
+ }
+
+ /// <summary>
+ /// Gets the name given to the n-th extra of a type that is named after its type.
+ /// </summary>
+ /// <param name="extraType">The extra type.</param>
+ /// <param name="seen">Number of extras of the type named before this one.</param>
+ /// <returns>The name.</returns>
+ private string GetNumberedExtraName(ExtraType extraType, int seen)
+ {
+ var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(extraType));
return seen == 0
? typeName
@@ -3799,7 +3848,7 @@ namespace Emby.Server.Implementations.Library
if (refreshLibrary)
{
- StartScanInBackground();
+ _ = StartScanInBackground();
}
else
{
@@ -3867,13 +3916,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)
@@ -3984,7 +4036,7 @@ namespace Emby.Server.Implementations.Library
{
await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
- StartScanInBackground();
+ _ = StartScanInBackground();
}
else
{
@@ -4128,6 +4180,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 b1547e72fe..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.Item.PrimaryVersionId.HasValue && 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,7 +286,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
if (personSourceRows.Count > 0)
{
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
- .Where(m => !m.Item.PrimaryVersionId.HasValue)
+ .Where(m => !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..6d52ce7440 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -62,12 +62,11 @@ namespace Emby.Server.Implementations.Library
// Playlist and BoxSet libraries require special handling because the folder only references linked items
if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
{
- var items = folder.GetItemList(new InternalItemsQuery(user)
- {
- ParentId = folder.ParentId
- });
+ var itemKind = folderViewType == CollectionType.playlists
+ ? BaseItemKind.Playlist
+ : BaseItemKind.BoxSet;
- if (!items.Any(item => item.IsVisible(user)))
+ if (!HasVisibleItem(itemKind, folders, user))
{
continue;
}
@@ -127,7 +126,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 +158,32 @@ namespace Emby.Server.Implementations.Library
.ToArray();
}
+ private bool HasVisibleItem(BaseItemKind itemKind, IReadOnlyList<Folder> folders, User user)
+ {
+ var topParentIds = folders.SelectMany(GetTopParentIds).ToArray();
+ if (topParentIds.Length == 0)
+ {
+ return false;
+ }
+
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = [itemKind],
+ TopParentIds = topParentIds,
+ GroupByPresentationUniqueKey = false,
+ DtoOptions = DtoOptions.StoredColumnsOnly
+ });
+
+ return items.Any(item => item.IsVisible(user));
+ }
+
+ private static IEnumerable<Guid> GetTopParentIds(Folder folder)
+ {
+ return folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0
+ ? collectionFolder.PhysicalFolderIds
+ : [folder.Id];
+ }
+
public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName)
{
var uniqueId = parentId + "subview" + type;
diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json
index 5686807d9a..98dcc68ed1 100644
--- a/Emby.Server.Implementations/Localization/Core/bs.json
+++ b/Emby.Server.Implementations/Localization/Core/bs.json
@@ -118,5 +118,8 @@
"NameExtraShort": "Kratko",
"NameExtraThemeSong": "Tema",
"NameExtraThemeVideo": "Tematski video",
- "NameExtraTrailer": "Najava"
+ "NameExtraTrailer": "Najava",
+ "LyricDownloadFailureFromForItem": "Tekstovi nisu uspješno preuzeti sa {0} za {1}",
+ "NameExtraUnknown": "Ekstra",
+ "Original": "Original"
}
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/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 023ed6ec5e..080fa69524 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -7,7 +7,7 @@
"AppDeviceValues": "App: {0}, Eind: {1}",
"Books": "Bøkur",
"ChapterNameValue": "Kapittul {0}",
- "Favorites": "Yndislista",
+ "Favorites": "Yndislisti",
"Folders": "Skjáttur",
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
@@ -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",
@@ -69,7 +69,7 @@
"TasksApplicationCategory": "Nýtsluskipan",
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd",
- "UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
+ "UserStoppedPlayingItemWithValues": "{0} gavst at spæla {1} á {2}",
"HomeVideos": "Heimaupptøkur",
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
"UserOfflineFromDevice": "{0} breyt av frá {1}",
@@ -96,9 +96,9 @@
"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",
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 2d38c173f0..c7e2b5f5e9 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -120,5 +120,6 @@
"NameExtraShort": "Kratki film",
"NameExtraThemeSong": "Glavna Pjesma",
"NameExtraThemeVideo": "Tema videa",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraUnknown": "Dodatno"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index 78b7ec744b..24008575d4 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -2,18 +2,18 @@
"AppDeviceValues": "アプリ: {0}, デバイス: {1}",
"Artists": "アーティスト",
"AuthenticationSucceededWithUserName": "{0} 認証に成功しました",
- "Books": "ブック",
+ "Books": "書籍",
"ChapterNameValue": "チャプター {0}",
"Collections": "コレクション",
"FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました",
"Favorites": "お気に入り",
"Folders": "フォルダー",
"Genres": "ジャンル",
- "HeaderContinueWatching": "再生を続ける",
+ "HeaderContinueWatching": "視聴をつづける",
"HeaderFavoriteEpisodes": "お気に入りのエピソード",
"HeaderFavoriteShows": "お気に入りの番組",
"HeaderLiveTV": "ライブTV",
- "HeaderNextUp": "次",
+ "HeaderNextUp": "次に再生",
"HomeVideos": "ホームビデオ",
"Inherit": "継承",
"LabelIpAddressValue": "IPアドレス: {0}",
@@ -41,7 +41,7 @@
"NotificationOptionServerRestartRequired": "サーバーを再起動してください",
"NotificationOptionTaskFailed": "スケジュールされていたタスクの失敗",
"NotificationOptionUserLockedOut": "ユーザーはロックされています",
- "NotificationOptionVideoPlayback": "ビデオの再生を開始",
+ "NotificationOptionVideoPlayback": "動画の再生を開始",
"NotificationOptionVideoPlaybackStopped": "ビデオの再生を停止",
"Photos": "フォト",
"PluginInstalledWithName": "{0} をインストールしました",
@@ -80,10 +80,10 @@
"TaskUpdatePlugins": "プラグインの更新",
"TaskRefreshPeopleDescription": "メディアライブラリー内の俳優や監督のメタデータを更新します。",
"TaskRefreshPeople": "俳優や監督のデータの更新",
- "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索する。",
- "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。",
+ "TaskDownloadMissingSubtitlesDescription": "メタデータ設定をもとに、欠落している字幕をインターネットで検索します。",
+ "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネールを作成します。",
"TaskRefreshChapterImages": "チャプター画像を抽出する",
- "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする",
+ "TaskDownloadMissingSubtitles": "欠けている字幕をダウンロード",
"TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。",
"TaskCleanActivityLog": "アクティビティの履歴を消去",
"Undefined": "未定義",
@@ -91,7 +91,7 @@
"Default": "デフォルト",
"TaskOptimizeDatabaseDescription": "データベースをコンパクトにして、空き領域を切り詰めます。メディアライブラリーのスキャンやその他のデータベースの更新を伴う変更の後でこのタスクを実行すると、パフォーマンスが向上します。",
"TaskOptimizeDatabase": "データベースの最適化",
- "TaskKeyframeExtractorDescription": "より正確なHLSプレイリストを作成するため、動画ファイルからキーフレームを抽出する。この処理には時間がかかる場合があります。",
+ "TaskKeyframeExtractorDescription": "動画ファイルからキーフレームを抽出し、より正確な HLS プレーリストを作成します。この処理には時間がかかる場合があります。",
"TaskKeyframeExtractor": "キーフレーム抽出",
"External": "外部",
"HearingImpaired": "聴覚障害の方",
@@ -103,10 +103,23 @@
"TaskExtractMediaSegments": "メディアセグメントを読み取る",
"TaskMoveTrickplayImages": "Trickplayの画像を移動",
"TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。",
- "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
+ "TaskDownloadMissingLyrics": "欠けている歌詞をダウンロード",
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。",
"LyricDownloadFailureFromForItem": "歌詞",
- "Original": "オリジナル"
+ "Original": "オリジナル",
+ "NameExtraThemeVideo": "テーマビデオ",
+ "NameExtraBehindTheScenes": "舞台裏",
+ "NameExtraClip": "クリップ",
+ "NameExtraDeletedScene": "未公開シーン",
+ "NameExtraFeaturette": "短編",
+ "NameExtraInterview": "インタビュー",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "サンプル",
+ "NameExtraScene": "シーン",
+ "NameExtraShort": "ショート",
+ "NameExtraThemeSong": "テーマ曲",
+ "NameExtraTrailer": "予告編",
+ "NameExtraUnknown": "特典"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json
index 21d31c5fbf..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",
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index dccec8067d..6baaeca524 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -120,5 +120,6 @@
"NameExtraShort": "Scurt",
"NameExtraThemeSong": "Audio de Fundal",
"NameExtraThemeVideo": "Video de Fundal",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraUnknown": "Extra"
}
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/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..7af545c550 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,33 +375,71 @@ 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;
+ }
+ var hasUnknownRating = false;
foreach (var ratingValue in ratingValues)
{
- var score = GetSingleRatingScore(ratingValue, countryCode);
+ // A single entry of such a list may be unrated while a later one still resolves
+ if (IsUnrated(ratingValue))
+ {
+ continue;
+ }
+
+ // An unknown entry is not worth a warning of its own as long as another entry still resolves
+ var score = GetSingleRatingScore(ratingValue, countryCode, logUnknownRating: false);
if (score is not null)
{
return score;
}
+
+ hasUnknownRating = true;
+ }
+
+ if (hasUnknownRating)
+ {
+ _logger.LogWarning(
+ "None of the ratings in '{Rating}' were found in a known rating system, treating as unrated",
+ rating);
}
return null;
}
/// <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)
+ private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode, bool logUnknownRating = true)
{
- // 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))
@@ -455,9 +494,11 @@ namespace Emby.Server.Implementations.Localization
}
}
- // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18"
- if (TryGetRatingScoreBySeparator(rating, ':', out var result)
- || TryGetRatingScoreBySeparator(rating, '-', out result))
+ // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18".
+ // A '/' marks a list of ratings, which this would split into the first entry's country and the last entry's rating
+ if (!rating.Contains('/', StringComparison.Ordinal)
+ && (TryGetRatingScoreBySeparator(rating, ':', logUnknownRating, out var result)
+ || TryGetRatingScoreBySeparator(rating, '-', logUnknownRating, out result)))
{
return result;
}
@@ -465,7 +506,7 @@ namespace Emby.Server.Implementations.Localization
return null;
}
- private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
+ private bool TryGetRatingScoreBySeparator(string rating, char separator, bool logUnknownRating, out ParentalRatingScore? result)
{
result = null;
@@ -512,10 +553,19 @@ namespace Emby.Server.Implementations.Localization
return true;
}
- _logger.LogWarning(
- "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
- rating,
- resolvedCountryCode);
+ // Explicitly unrated content (e.g. "IT-NR") is unrated by definition, not a lookup failure
+ if (IsUnrated(ratingPart))
+ {
+ return true;
+ }
+
+ if (logUnknownRating)
+ {
+ _logger.LogWarning(
+ "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
+ rating,
+ resolvedCountryCode);
+ }
return true;
}
@@ -527,14 +577,16 @@ namespace Emby.Server.Implementations.Localization
}
/// <summary>
- /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
+ /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+")
+ /// or a leading '-' (e.g. the French "-12").
/// </summary>
/// <param name="ratingValue">Rating value to parse.</param>
/// <param name="score">Parsed score.</param>
/// <returns>Returns true if parsing was successful.</returns>
private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
{
- var trimmed = ratingValue.TrimEnd('+');
+ // A leading '-' marks a minimum age ("-12" is French for "not for under 12s"), never a negative score
+ var trimmed = ratingValue.TrimStart('-').TrimEnd('+');
return int.TryParse(trimmed, out score);
}
diff --git a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json
index b390151611..dd88b35363 100644
--- a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json
+++ b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json
@@ -24,7 +24,7 @@
}
},
{
- "ratingStrings": ["X", "XX", "XXX", "XXXX"],
+ "ratingStrings": ["X", "XX", "XXX", "XXXX", "P"],
"ratingScore": {
"score": 1000,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/ar.json b/Emby.Server.Implementations/Localization/Ratings/ar.json
index 73dfd2c7c9..20053fe575 100644
--- a/Emby.Server.Implementations/Localization/Ratings/ar.json
+++ b/Emby.Server.Implementations/Localization/Ratings/ar.json
@@ -3,35 +3,49 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["ATP"],
+ "ratingStrings": ["ATP", "Apta para todo público", "G"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["+13"],
+ "ratingStrings": ["SP"],
+ "ratingScore": {
+ "score": 10,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["+13", "SAM 13", "SAM13", "R-13"],
"ratingScore": {
"score": 13,
"subScore": null
}
},
{
- "ratingStrings": ["+16"],
+ "ratingStrings": ["+16", "SAM 16", "SAM16"],
"ratingScore": {
"score": 16,
"subScore": null
}
},
{
- "ratingStrings": ["+18"],
+ "ratingStrings": ["R-17"],
+ "ratingScore": {
+ "score": 17,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["+18", "SAM 18", "SAM18"],
"ratingScore": {
"score": 18,
"subScore": null
}
},
{
- "ratingStrings": ["C"],
+ "ratingStrings": ["C", "+18 C", "SAM 18 C", "SAM 18C", "SAM18C"],
"ratingScore": {
"score": 1001,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/au.json b/Emby.Server.Implementations/Localization/Ratings/au.json
index a563df899d..c5d4c97443 100644
--- a/Emby.Server.Implementations/Localization/Ratings/au.json
+++ b/Emby.Server.Implementations/Localization/Ratings/au.json
@@ -52,7 +52,7 @@
}
},
{
- "ratingStrings": ["X", "X18", "X 18"],
+ "ratingStrings": ["X", "X18", "X 18", "X18+", "X 18+"],
"ratingScore": {
"score": 1000,
"subScore": 0
diff --git a/Emby.Server.Implementations/Localization/Ratings/de.json b/Emby.Server.Implementations/Localization/Ratings/de.json
index 30c34b230c..2a925f999f 100644
--- a/Emby.Server.Implementations/Localization/Ratings/de.json
+++ b/Emby.Server.Implementations/Localization/Ratings/de.json
@@ -3,35 +3,35 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["0", "FSK 0", "FSK-0", "Educational", "Infoprogramm"],
+ "ratingStrings": ["0", "FSK 0", "FSK-0", "FSK0", "ab 0", "Educational", "Infoprogramm"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["6", "FSK 6", "FSK-6"],
+ "ratingStrings": ["6", "FSK 6", "FSK-6", "FSK6", "ab 6"],
"ratingScore": {
"score": 6,
"subScore": null
}
},
{
- "ratingStrings": ["12", "FSK 12", "FSK-12"],
+ "ratingStrings": ["12", "FSK 12", "FSK-12", "FSK12", "ab 12"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
- "ratingStrings": ["16", "FSK 16", "FSK-16"],
+ "ratingStrings": ["16", "FSK 16", "FSK-16", "FSK16", "ab 16"],
"ratingScore": {
"score": 16,
"subScore": null
}
},
{
- "ratingStrings": ["18", "FSK 18", "FSK-18"],
+ "ratingStrings": ["18", "FSK 18", "FSK-18", "FSK18", "ab 18"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.json b/Emby.Server.Implementations/Localization/Ratings/fr.json
index e8bafd6b87..333e467a08 100644
--- a/Emby.Server.Implementations/Localization/Ratings/fr.json
+++ b/Emby.Server.Implementations/Localization/Ratings/fr.json
@@ -24,14 +24,14 @@
}
},
{
- "ratingStrings": ["10"],
+ "ratingStrings": ["10", "Interdit aux moins de 10 ans"],
"ratingScore": {
"score": 10,
"subScore": null
}
},
{
- "ratingStrings": ["12"],
+ "ratingStrings": ["12", "–12", "Interdit aux moins de 12 ans"],
"ratingScore": {
"score": 12,
"subScore": null
@@ -45,14 +45,14 @@
}
},
{
- "ratingStrings": ["16"],
+ "ratingStrings": ["16", "–16", "Interdit aux moins de 16 ans"],
"ratingScore": {
"score": 16,
"subScore": null
}
},
{
- "ratingStrings": ["18"],
+ "ratingStrings": ["18", "–18", "Interdit aux moins de 18 ans"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/id.json b/Emby.Server.Implementations/Localization/Ratings/id.json
index 8c687c2320..f55e4e4333 100644
--- a/Emby.Server.Implementations/Localization/Ratings/id.json
+++ b/Emby.Server.Implementations/Localization/Ratings/id.json
@@ -3,14 +3,28 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["SU"],
+ "ratingStrings": ["SU", "SU-BO", "Semua Umur"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["13+"],
+ "ratingStrings": ["P", "P2+", "P-BO", "Prasekolah"],
+ "ratingScore": {
+ "score": 2,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["A", "A7+", "A-BO", "Anak"],
+ "ratingScore": {
+ "score": 7,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["13+", "R", "R13+", "R-BO", "Remaja"],
"ratingScore": {
"score": 13,
"subScore": null
@@ -24,6 +38,13 @@
}
},
{
+ "ratingStrings": ["D", "D18+", "Dewasa"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
"ratingStrings": ["21+"],
"ratingScore": {
"score": 21,
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/Localization/Ratings/tw.json b/Emby.Server.Implementations/Localization/Ratings/tw.json
index a7869c1221..9af7dd3128 100644
--- a/Emby.Server.Implementations/Localization/Ratings/tw.json
+++ b/Emby.Server.Implementations/Localization/Ratings/tw.json
@@ -3,35 +3,35 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["0+"],
+ "ratingStrings": ["0+", "普遍級", "普"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["6+"],
+ "ratingStrings": ["6+", "保護級", "護"],
"ratingScore": {
"score": 6,
"subScore": null
}
},
{
- "ratingStrings": ["12+"],
+ "ratingStrings": ["12+", "輔12級", "輔導12歲級", "輔導級"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
- "ratingStrings": ["15+"],
+ "ratingStrings": ["15+", "輔15級", "輔導15歲級"],
"ratingScore": {
"score": 15,
"subScore": null
}
},
{
- "ratingStrings": ["18+"],
+ "ratingStrings": ["18+", "限制級", "限"],
"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/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 923bfc67aa..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.
@@ -115,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>
@@ -163,6 +186,8 @@ namespace Emby.Server.Implementations.SyncPlay
Ping = DefaultPing,
IsBuffering = false
});
+
+ _participantSessions[session.Id] = session;
}
/// <summary>
@@ -172,6 +197,8 @@ namespace Emby.Server.Implementations.SyncPlay
private void RemoveSession(SessionInfo session)
{
_participants.Remove(session.Id);
+ _participantSessions.Remove(session.Id);
+ UpdateGroupWaitDeadline(false);
}
/// <summary>
@@ -389,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 />
@@ -475,6 +509,8 @@ namespace Emby.Server.Implementations.SyncPlay
{
value.IsBuffering = isBuffering;
}
+
+ UpdateGroupWaitDeadline(false);
}
/// <inheritdoc />
@@ -484,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 />
@@ -690,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 b88ee33358..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))
{
@@ -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();
}
}
}
@@ -384,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)