aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-10-24 09:56:04 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-10-24 09:56:04 +0200
commitd95c2e1425d0d902e8191260ba9151f686aa2650 (patch)
tree3bc937664dcf9fe8d7659b00d92fc3a6c61be805 /Emby.Server.Implementations
parentd3a92c69f8c1c5d1cda803a35f03175d6ba3b091 (diff)
parentc7ce6a1b56ee7dcc1896250df0f2f9deaa828a81 (diff)
Merge branch 'master' into port-async-migration-routines
# Conflicts: # Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs
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/CleanDatabaseScheduledTask.cs2
-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.cs138
-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.cs38
-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/UserDataManager.cs16
-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/et.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/hi.json140
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/hy.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json35
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/lb.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json5
-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/ur_PK.json25
-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.cs24
-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
52 files changed, 1055 insertions, 262 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/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
index 17355960c3..81ae9a536b 100644
--- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
+++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
@@ -113,7 +113,7 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
- await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await context.ItemValues.Where(e => !e.BaseItemsMap!.Any()).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
subProgress.Report(50);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
subProgress.Report(100);
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..16616dd328 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)
@@ -2442,6 +2452,9 @@ namespace Emby.Server.Implementations.Library
var parentCollectionType = parent is not null ? GetTopFolderContentType(parent) : null;
foreach (var item in items)
{
+ // Parental controls filter on the stored score, so it has to match the ratings being saved.
+ item.OnMetadataChanged();
+
if (item is Video video && video.LocalAlternateVersions.Length > 0)
{
var videoType = video.GetType();
@@ -2648,6 +2661,9 @@ namespace Emby.Server.Implementations.Library
{
foreach (var item in items)
{
+ // Parental controls filter on the stored score, so it has to match the ratings being saved.
+ item.OnMetadataChanged();
+
item.DateLastSaved = DateTime.UtcNow;
await RunMetadataSavers(item, updateReason).ConfigureAwait(false);
@@ -3042,14 +3058,7 @@ namespace Emby.Server.Implementations.Library
CollectionType? viewType,
string sortName)
{
- var parentIdString = parentId.IsEmpty()
- ? null
- : parentId.ToString("N", CultureInfo.InvariantCulture);
-
- // The name is either localized (grouped views) or the library folder's own name.
- var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
-
- var id = GetNewItemId(idValues, typeof(UserView));
+ var id = GetNamedViewId(user, parentId, viewType);
var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N", CultureInfo.InvariantCulture));
@@ -3107,6 +3116,18 @@ namespace Emby.Server.Implementations.Library
return item;
}
+ public Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType)
+ {
+ var parentIdString = parentId.IsEmpty()
+ ? null
+ : parentId.ToString("N", CultureInfo.InvariantCulture);
+
+ // The name is either localized (grouped views) or the library folder's own name.
+ var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
+
+ return GetNewItemId(idValues, typeof(UserView));
+ }
+
public UserView GetShadowView(
BaseItem parent,
CollectionType? viewType,
@@ -3116,10 +3137,7 @@ namespace Emby.Server.Implementations.Library
var name = parent.Name;
var parentId = parent.Id;
-
- var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty);
-
- var id = GetNewItemId(idValues, typeof(UserView));
+ var id = GetShadowViewId(name, parentId, viewType);
var path = parent.Path;
@@ -3171,6 +3189,13 @@ namespace Emby.Server.Implementations.Library
return item;
}
+ public Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType)
+ {
+ var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty);
+
+ return GetNewItemId(idValues, typeof(UserView));
+ }
+
public UserView GetNamedView(
string name,
Guid parentId,
@@ -3456,6 +3481,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 +3518,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 +3545,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 +3596,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
@@ -3608,6 +3672,11 @@ namespace Emby.Server.Implementations.Library
public QueryResult<BaseItem> GetPeopleItems(InternalPeopleQuery query)
{
+ ArgumentNullException.ThrowIfNull(query);
+
+ // This hands back by-name items, so the people without one are not ours to report.
+ query.MustHaveItem = true;
+
var queryResult = _peopleRepository.GetPeople(query);
var baseItems = queryResult.Items.Select(i =>
{
@@ -3799,7 +3868,7 @@ namespace Emby.Server.Implementations.Library
if (refreshLibrary)
{
- StartScanInBackground();
+ _ = StartScanInBackground();
}
else
{
@@ -3867,13 +3936,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 +4056,7 @@ namespace Emby.Server.Implementations.Library
{
await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
- StartScanInBackground();
+ _ = StartScanInBackground();
}
else
{
@@ -4128,6 +4200,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..a5ea02df51 100644
--- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
+++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
@@ -10,6 +10,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
@@ -36,6 +37,7 @@ public class SqlSearchProvider : IInternalSearchProvider
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IItemQueryHelpers _queryHelpers;
+ private readonly IServerConfigurationManager _configurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
@@ -45,18 +47,21 @@ public class SqlSearchProvider : IInternalSearchProvider
/// <param name="libraryManager">The library manager.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="queryHelpers">The shared item query helpers.</param>
+ /// <param name="configurationManager">The configuration manager.</param>
public SqlSearchProvider(
IDbContextFactory<JellyfinDbContext> dbProvider,
IItemTypeLookup itemTypeLookup,
ILibraryManager libraryManager,
IUserManager userManager,
- IItemQueryHelpers queryHelpers)
+ IItemQueryHelpers queryHelpers,
+ IServerConfigurationManager configurationManager)
{
_dbProvider = dbProvider;
_itemTypeLookup = itemTypeLookup;
_libraryManager = libraryManager;
_userManager = userManager;
_queryHelpers = queryHelpers;
+ _configurationManager = configurationManager;
}
/// <inheritdoc/>
@@ -98,6 +103,12 @@ public class SqlSearchProvider : IInternalSearchProvider
// so match it via a case-insensitive LIKE rather than a per-row case conversion
// that may not translate to SQL on every provider.
var likeOriginal = $"%{rawSearchTerm}%";
+
+ // Great benefit for users with metadata in non-latin script
+ // since SortName is among other things stored transliterated.
+ var sortNameShape = BaseItem.GetSortName(rawSearchTerm, true, _configurationManager.Configuration);
+ var likeSortName = string.IsNullOrWhiteSpace(sortNameShape) ? null : $"%{sortNameShape}%";
+
var limit = query.Limit ?? DefaultSearchLimit;
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
@@ -107,14 +118,27 @@ public class SqlSearchProvider : IInternalSearchProvider
var dbQuery = dbContext.BaseItems
.AsNoTracking()
.Where(e => e.Id != _placeholderId)
- .Where(e => !e.IsVirtualItem)
- .Where(e => e.CleanName!.Contains(cleanSearchTerm)
- || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
+ .Where(e => !e.IsVirtualItem);
+
+ if (likeSortName is null)
+ {
+ dbQuery = dbQuery
+ .Where(e => e.CleanName!.Contains(cleanSearchTerm)
+ || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
+ }
+ else
+ {
+ dbQuery = dbQuery
+ .Where(e => e.CleanName!.Contains(cleanSearchTerm)
+ || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))
+ || (e.SortName != null && EF.Functions.Like(e.SortName, likeSortName)));
+ }
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
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 +217,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/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 0680046c11..1e7068b24b 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -75,6 +75,22 @@ namespace Emby.Server.Implementations.Library
}
dbContext.SaveChanges();
+
+ // Rows under keys the item no longer reports are kept, not deleted: the key set is briefly
+ // incomplete mid-refresh, and those rows are what a deleted and re-added item reattaches by.
+ dbContext.UserData
+ .Where(e => e.ItemId == item.Id && e.UserId == user.Id && !keys.Contains(e.CustomDataKey))
+ .ExecuteUpdate(e => e
+ .SetProperty(f => f.AudioStreamIndex, userData.AudioStreamIndex)
+ .SetProperty(f => f.IsFavorite, userData.IsFavorite)
+ .SetProperty(f => f.LastPlayedDate, userData.LastPlayedDate)
+ .SetProperty(f => f.Likes, userData.Likes)
+ .SetProperty(f => f.PlaybackPositionTicks, userData.PlaybackPositionTicks)
+ .SetProperty(f => f.PlayCount, userData.PlayCount)
+ .SetProperty(f => f.Played, userData.Played)
+ .SetProperty(f => f.Rating, userData.Rating)
+ .SetProperty(f => f.SubtitleStreamIndex, userData.SubtitleStreamIndex));
+
transaction.Commit();
var userId = user.InternalId;
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/et.json b/Emby.Server.Implementations/Localization/Core/et.json
index a7afcf5b77..10e0e6c3b3 100644
--- a/Emby.Server.Implementations/Localization/Core/et.json
+++ b/Emby.Server.Implementations/Localization/Core/et.json
@@ -95,16 +95,16 @@
"HearingImpaired": "Kuulmispuudega",
"TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadrid, et luua täpsemaid HLS-i esitusloendeid. See võib kesta pikka aega.",
"TaskKeyframeExtractor": "Eralda võtmekaadrid",
- "TaskRefreshTrickplayImages": "Loo trickplay pildid",
- "TaskRefreshTrickplayImagesDescription": "Loob trickplay eelvaated videotele lubatud meediakogudes.",
+ "TaskRefreshTrickplayImages": "Loo kerimispildid (trickplay)",
+ "TaskRefreshTrickplayImagesDescription": "Loob kerimispidid videotele lubatud meediakogudes.",
"TaskAudioNormalization": "Normaliseeri helitugevus",
"TaskAudioNormalizationDescription": "Otsib failidest helitugevuse normaliseerimise teavet.",
"TaskDownloadMissingLyrics": "Hangi puuduvad laulusõnad",
"TaskDownloadMissingLyricsDescription": "Laulusõnade allalaadimine",
- "TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.",
+ "TaskMoveTrickplayImagesDescription": "Teisaldab kerimispildid meediakogu sätete kohaselt.",
"TaskExtractMediaSegments": "Skaneeri meedialõike",
"TaskExtractMediaSegmentsDescription": "Eraldab või võtab meedialõigud MediaSegment'i toega pluginatest.",
- "TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht",
+ "TaskMoveTrickplayImages": "Muuda kerimispiltide asukoht",
"CleanupUserDataTask": "Puhasta kasutajaandmed",
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.",
"LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus",
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index 9a453120dd..12d50cf89a 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -120,5 +120,6 @@
"NameExtraShort": "Lyhytfilmi",
"NameExtraThemeSong": "Tunnusmusiikki",
"NameExtraThemeVideo": "Tunnusvideo",
- "NameExtraTrailer": "Traileri"
+ "NameExtraTrailer": "Traileri",
+ "NameExtraUnknown": "Lisät"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 023ed6ec5e..b9964b02b8 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",
@@ -49,7 +49,7 @@
"Movies": "Filmar",
"MixedContent": "Blandað innihald",
"Music": "Tónleikur",
- "UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}",
+ "UserStartedPlayingItemWithValues": "{0} spælir {1} á {2}",
"HeaderContinueWatching": "Hald áfram at hyggja",
"MusicVideos": "Sjónbandaløg",
"TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.",
@@ -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/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json
index f4b1f86d1d..766a09ffc9 100644
--- a/Emby.Server.Implementations/Localization/Core/hi.json
+++ b/Emby.Server.Implementations/Localization/Core/hi.json
@@ -1,106 +1,106 @@
{
- "HeaderNextUp": "इसके बाद",
+ "HeaderNextUp": "इसके आगे",
"HeaderLiveTV": "लाइव टीवी",
"HeaderFavoriteShows": "पसंदीदा शो",
- "HeaderFavoriteEpisodes": "पसंदीदा प्रकरण",
- "HeaderContinueWatching": "देखना जारी रखें",
+ "HeaderFavoriteEpisodes": "पसंदीदा भाग",
+ "HeaderContinueWatching": "देखते रहें",
"Genres": "शैलियां",
- "Forced": "बलपूर्वक",
+ "Forced": "ज़रूरी",
"Folders": "फ़ोल्डर",
"Favorites": "पसंदीदा",
"FailedLoginAttemptWithUserName": "{0} से संप्रवेश असफल हुआ",
- "Default": "प्राथमिक",
+ "Default": "मूल",
"Collections": "संग्रह",
"ChapterNameValue": "अध्याय {0}",
- "Books": "पुस्तकें",
- "AuthenticationSucceededWithUserName": "{0} सफलतापूर्वक प्रमाणित किया गया",
+ "Books": "किताबें",
+ "AuthenticationSucceededWithUserName": "{0} प्रमाणीकरण सफल हुआ",
"Artists": "कलाकार",
"AppDeviceValues": "एप: {0}, उपकरण: {1}",
"NotificationOptionPluginUninstalled": "प्लगइन अनइंस्टाल हो गया",
"NotificationOptionPluginInstalled": "प्लगइन इनस्टॉल हो गया",
- "NotificationOptionPluginError": "प्लगइन फ़ैल हो गया",
- "NotificationOptionInstallationFailed": "इंस्टालेशन फ़ैल हो गया",
+ "NotificationOptionPluginError": "प्लगिन असफल",
+ "NotificationOptionInstallationFailed": "इंस्टालेशन असफल",
"NotificationOptionAudioPlaybackStopped": "संगीत बंद कर दिया गया",
- "NotificationOptionAudioPlayback": "संगीत शुरू कर दिया गया",
- "NotificationOptionCameraImageUploaded": "कैमरा फोटो अपलोड किया गया",
- "NotificationOptionApplicationUpdateInstalled": "एप्लीकेशन अपडेट इनस्टॉल कर दिया है",
- "NotificationOptionApplicationUpdateAvailable": "एप्लीकेशन अपडेट उपलभ्द है",
+ "NotificationOptionAudioPlayback": "आवाज़ चलनी शुरू हो गई",
+ "NotificationOptionCameraImageUploaded": "कैमरा फोटो अपलोड हो गई",
+ "NotificationOptionApplicationUpdateInstalled": "एप्लीकेशन अपडेट इनस्टॉल हो गया",
+ "NotificationOptionApplicationUpdateAvailable": "एप्लीकेशन अपडेट उपलब्ध है",
"NewVersionIsAvailable": "जेलीफिन सर्वर का एक नया वर्जन डाउनलोड के लिए उपलब्ध है।",
- "NameSeasonUnknown": "अनजान भाग",
- "NameSeasonNumber": "भाग {0}",
- "NameInstallFailed": "{0} इनस्टॉल करते समय फेल हो गया है",
+ "NameSeasonUnknown": "अनजान कड़ी",
+ "NameSeasonNumber": "कड़ी {0}",
+ "NameInstallFailed": "{0} इंस्टॉलेशन असफल रहा",
"MusicVideos": "संगीत वीडियो",
"Music": "संगीत",
- "Movies": "फ़िल्म",
+ "Movies": "फ़िल्में",
"MixedContent": "मिला-जुला कंटेंट",
"Latest": "सबसे नया",
"LabelIpAddressValue": "आई पी एड्रेस: {0}",
- "HomeVideos": "होम चलचित्र",
- "NotificationOptionVideoPlayback": "वीडियो प्लेबैक शुरू हुआ",
- "NotificationOptionUserLockedOut": "उपयोगकर्ता लॉक हो गया",
- "NotificationOptionTaskFailed": "निर्धारित कार्य विफलता",
- "NotificationOptionServerRestartRequired": "सर्वर पुनरारंभ आवश्यक है",
- "NotificationOptionPluginUpdateInstalled": "प्लगइन अद्यतन स्थापित",
- "NotificationOptionNewLibraryContent": "नई सामग्री जोड़ी गई",
- "LabelRunningTimeValue": "चलने का समय: {0}",
- "Inherit": "इनहेरिट",
- "NotificationOptionVideoPlaybackStopped": "चलचित्र रुका हुआ",
- "PluginUninstalledWithName": "{0} अनइंस्टॉल हुए",
- "PluginInstalledWithName": "{0} इंस्टॉल हुए",
+ "HomeVideos": "घर के वीडियो",
+ "NotificationOptionVideoPlayback": "वीडियो चल गया",
+ "NotificationOptionUserLockedOut": "यूज़र लॉक हो गया",
+ "NotificationOptionTaskFailed": "निर्धारित कार्य विफल रहा",
+ "NotificationOptionServerRestartRequired": "सर्वर रिस्टार्ट की ज़रूरत है",
+ "NotificationOptionPluginUpdateInstalled": "प्लगिन अपडेट इंस्टॉल हो गया है",
+ "NotificationOptionNewLibraryContent": "नई चीजें जोड़ी गई",
+ "LabelRunningTimeValue": "अवधि : {0}",
+ "Inherit": "ऊपर से लागू",
+ "NotificationOptionVideoPlaybackStopped": "वीडियो बंद हो गया",
+ "PluginUninstalledWithName": "{0} अनइंस्टॉल हो गए",
+ "PluginInstalledWithName": "{0} इंस्टॉल हो गए",
"Photos": "तस्वीरें",
"External": "बाहरी",
"PluginUpdatedWithName": "{0} अपडेट हुए",
- "UserStartedPlayingItemWithValues": "{0} {2} पर {1} खेल रहे हैं",
- "UserStoppedPlayingItemWithValues": "{0} ने {2} पर {1} खेलना खत्म किया",
- "StartupEmbyServerIsLoading": "जेलीफ़िन सर्वर लोड हो रहा है। कृपया शीघ्र ही पुन: प्रयास करें।",
- "UserCreatedWithName": "उपयोगकर्ता {0} बनाया गया",
+ "UserStartedPlayingItemWithValues": "{0} {2} पर {1} चला रहे हैं",
+ "UserStoppedPlayingItemWithValues": "{0} ने {2} पर {1} ख़त्म कर दिया",
+ "StartupEmbyServerIsLoading": "जेलीफ़िन सर्वर लोड हो रहा है। कृपया थोड़ी देर में दोबारा कोशिश करें।",
+ "UserCreatedWithName": "यूज़र {0} बनाया गया",
"UserDownloadingItemWithValues": "{0} डाउनलोड हो रहा है",
"UserOfflineFromDevice": "{0} {1} से डिस्कनेक्ट हो गया है",
"Undefined": "अनिर्धारित",
"UserOnlineFromDevice": "{0} {1} से ऑनलाइन है",
"Shows": "शो",
- "UserPasswordChangedWithName": "उपयोगकर्ता {0} के लिए पासवर्ड बदल दिया गया है",
- "UserDeletedWithName": "उपयोगकर्ता {0} हटा दिया गया",
- "SubtitleDownloadFailureFromForItem": "{1} के लिए {0} से उपशीर्षक डाउनलोड करने में विफल",
+ "UserPasswordChangedWithName": "{0} यूज़र का पासवर्ड बदल दिया गया है",
+ "UserDeletedWithName": "यूज़र {0} हटा दिया गया",
+ "SubtitleDownloadFailureFromForItem": "{1} के लिए {0} से सबटाइटल डाउनलोड करने में विफल",
"ScheduledTaskFailedWithName": "{0}असफल",
- "UserLockedOutWithName": "उपयोगकर्ता {0} को लॉक आउट कर दिया गया है",
+ "UserLockedOutWithName": "यूज़र {0} को बाहर लॉक कर दिया गया है",
"TvShows": "टीवी शो",
"HearingImpaired": "मूक बधिर",
"TasksMaintenanceCategory": "रखरखाव",
"VersionNumber": "{0} पाठान्तर",
- "TasksLibraryCategory": "संग्रहालय",
- "TaskOptimizeDatabase": "जानकारी प्रवृद्धि",
- "TaskDownloadMissingSubtitles": "लापता अनुलेख डाउनलोड करें",
- "TaskRefreshLibrary": "माध्यम संग्राहत को छाने",
- "TaskCleanActivityLog": "क्रियाकलाप लॉग साफ करें",
- "TasksChannelsCategory": "इंटरनेट प्रणाली",
- "TasksApplicationCategory": "अनुप्रयोग",
- "TaskRefreshPeople": "लोगोकी जानकारी ताज़ी करें",
+ "TasksLibraryCategory": "लाइब्रेरी",
+ "TaskOptimizeDatabase": "डेटाबेस ट्यून करें",
+ "TaskDownloadMissingSubtitles": "बाक़ी सबटाइटल डाउनलोड करें",
+ "TaskRefreshLibrary": "मीडिया लाइब्रेरी को छानें",
+ "TaskCleanActivityLog": "एक्टिविटी लॉग हटायें",
+ "TasksChannelsCategory": "इंटरनेट चैनल",
+ "TasksApplicationCategory": "ऐप्लिकेशन",
+ "TaskRefreshPeople": "लोगों की जानकारी ताज़ा करें",
"TaskKeyframeExtractor": "कीफ़्रेम एक्सट्रैक्टर",
"TaskCleanActivityLogDescription": "कॉन्फ़िगर की गई आयु से पुरानी गतिविधि लॉग प्रविष्टियां हटाता है।",
- "TaskRefreshChapterImagesDescription": "अध्याय वाले वीडियो के लिए थंबनेल बनाता है।",
+ "TaskRefreshChapterImagesDescription": "अध्याय वाले वीडियो के लिए झलकें बनाता है।",
"TaskRefreshLibraryDescription": "नई फ़ाइलों के लिए आपकी मीडिया लाइब्रेरी को स्कैन करता है और मेटाडेटा को ताज़ा करता है।",
- "TaskCleanLogs": "स्वच्छ लॉग निर्देशिका",
- "TaskUpdatePluginsDescription": "प्लगइन्स के लिए अपडेट डाउनलोड और इंस्टॉल करें जो स्वचालित रूप से अपडेट करने के लिए कॉन्फ़िगर किए गए हैं।",
- "TaskCleanTranscode": "स्वच्छ ट्रांसकोड निर्देशिका",
- "TaskCleanTranscodeDescription": "एक दिन से अधिक पुरानी ट्रांसकोड फ़ाइलें हटाता है.",
+ "TaskCleanLogs": "लॉग डाइरेक्टरी ख़ाली करें",
+ "TaskUpdatePluginsDescription": "अपने आप अपडेट होने वाले निर्धारित प्लगिंस के लिए अपडेट डाउनलोड और इंस्टॉल करता है।",
+ "TaskCleanTranscode": "ट्रांसकोड डाइरेक्टरी ख़ाली करें",
+ "TaskCleanTranscodeDescription": "एक दिन से ज़्यादा पुरानी ट्रांसकोड फ़ाइलें हटाता है।",
"TaskRefreshChannelsDescription": "इंटरनेट चैनल की जानकारी को ताज़ा करता है।",
- "TaskOptimizeDatabaseDescription": "डेटाबेस को कॉम्पैक्ट करता है और मुक्त स्थान को छोटा करता है। लाइब्रेरी को स्कैन करने के बाद इस कार्य को चलाने या अन्य परिवर्तन करने से जो डेटाबेस संशोधनों को लागू करते हैं, प्रदर्शन में सुधार कर सकते हैं।",
- "TaskRefreshChannels": "इंटरनेट चैनल की जानकारी को ताज़ा करता है",
- "TaskRefreshChapterImages": "अध्याय छवियाँ निकालें",
+ "TaskOptimizeDatabaseDescription": "डेटाबेस को छोटा करके जगह ख़ाली करता है। लाइब्रेरी का निरीक्षण या डेटाबेस में बदलाव के बाद इस कार्य को चलाने से कार्यकुशलता बढ़ सकती है।",
+ "TaskRefreshChannels": "चैनल ताज़ा करें",
+ "TaskRefreshChapterImages": "अध्याय की तस्वीरें निकालें",
"TaskCleanLogsDescription": "{0} दिन से अधिक पुरानी लॉग फ़ाइलें हटाता है।",
- "TaskCleanCacheDescription": "उन कैश फ़ाइलों को हटाता है जिनकी अब सिस्टम को आवश्यकता नहीं है।",
- "TaskUpdatePlugins": "अद्यतन प्लगइन्स",
+ "TaskCleanCacheDescription": "अनावश्यक अस्थायी डेटा फ़ाइलों को हटाता है।",
+ "TaskUpdatePlugins": "प्लगिन अपडेट करें",
"TaskRefreshPeopleDescription": "आपकी मीडिया लाइब्रेरी में अभिनेताओं और निर्देशकों के लिए मेटाडेटा अपडेट करता है।",
- "TaskCleanCache": "स्वच्छ कैश निर्देशिका",
- "TaskDownloadMissingSubtitlesDescription": "मेटाडेटा कॉन्फ़िगरेशन के आधार पर लापता उपशीर्षक के लिए इंटरनेट खोजता है।",
+ "TaskCleanCache": "अस्थायी डेटा डाइरेक्टरी ख़ाली करें",
+ "TaskDownloadMissingSubtitlesDescription": "मेटाडेटा कॉन्फ़िगरेशन के आधार पर लापता सबटाइटल की इंटरनेट पर खोज करता है।",
"TaskKeyframeExtractorDescription": "अधिक सटीक एचएलएस प्लेलिस्ट बनाने के लिए वीडियो फ़ाइलों से मुख्य-फ़्रेम निकालता है। यह कार्य लंबे समय तक चल सकता है।",
- "TaskRefreshTrickplayImages": "ट्रिकप्लै चित्रों को सृजन करे",
- "TaskRefreshTrickplayImagesDescription": "नियत संग्रहों में चलचित्रों का ट्रीकप्लै दर्शनों को सृजन करे.",
- "TaskAudioNormalization": "श्रव्य सामान्यीकरण",
- "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें।",
- "TaskDownloadMissingLyrics": "लापता गानों के बोल डाउनलोड करेँ",
- "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है",
+ "TaskRefreshTrickplayImages": "ट्रिकप्ले तस्वीरें बनायें",
+ "TaskRefreshTrickplayImagesDescription": "चुनिंदा लाइब्रेरी के वीडियो के लिए ट्रिकप्ले तस्वीरें (सीन झलकियां) बनाता है।",
+ "TaskAudioNormalization": "ध्वनि सामान्यीकरण",
+ "TaskAudioNormalizationDescription": "ध्वनि सामान्यीकरण के लिए फाइलें छानें।",
+ "TaskDownloadMissingLyrics": "बाकी बोल डाउनलोड करें",
+ "TaskDownloadMissingLyricsDescription": "गीत के बोल डाउनलोड करें",
"TaskExtractMediaSegments": "मीडिया सेगमेंट स्कैन",
"TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
@@ -108,18 +108,18 @@
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
"Original": "असली",
"LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा",
- "NameExtraBehindTheScenes": "परदे के पीछे",
+ "NameExtraBehindTheScenes": "पर्दे के पीछे",
"NameExtraClip": "क्लिप",
- "NameExtraDeletedScene": "हटाया गया दृश्य",
+ "NameExtraDeletedScene": "काटे गए सीन",
"NameExtraFeaturette": "फीचरेट",
"NameExtraInterview": "साक्षात्कार",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "नमूना",
- "NameExtraScene": "दृश्य",
+ "NameExtraScene": "सीन",
"NameExtraShort": "शॉर्ट",
- "NameExtraThemeSong": "थीम सॉन्ग",
- "NameExtraThemeVideo": "थीम वीडियो",
+ "NameExtraThemeSong": "विशेष गीत",
+ "NameExtraThemeVideo": "विशेष वीडियो",
"NameExtraTrailer": "ट्रेलर",
- "NameExtraUnknown": "अतिरिक्त",
- "CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।"
+ "NameExtraUnknown": "और भी",
+ "CleanupUserDataTaskDescription": "९० दिन या उससे ज़्यादा से ग़ायब मीडिया से सभी यूज़र डेटा (देखें गए, पसंदीदा आदि) को हटाता है।"
}
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/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json
index 80be71ed70..09f1bfe79c 100644
--- a/Emby.Server.Implementations/Localization/Core/hy.json
+++ b/Emby.Server.Implementations/Localization/Core/hy.json
@@ -29,5 +29,15 @@
"Default": "Լռելյայն",
"Favorites": "Ընտրյալ",
"Forced": "Ստիպուած",
- "Genres": "Ոճ"
+ "Genres": "Ոճ",
+ "HeaderFavoriteEpisodes": "Սիրելի դրվագներ",
+ "HeaderFavoriteShows": "Սիրելի հաղորդումներ",
+ "HeaderLiveTV": "Ուղիղ եթեր",
+ "HeaderNextUp": "Հաջորդը",
+ "HomeVideos": "Տնային տեսանյութեր",
+ "LabelRunningTimeValue": "Տևողությունը: {0}",
+ "Latest": "Վերջինը",
+ "LyricDownloadFailureFromForItem": "Չհաջողվեց ներբեռնել {1}-ի երգի բառերը {0}-ից",
+ "MixedContent": "Խառը բովանդակություն",
+ "CleanupUserDataTaskDescription": "Մաքրում է օգտատիրոջ բոլոր տվյալները (դիտման կարգավիճակ, «սիրելի» նշում և այլն) այն մեդիաֆայլերից, որոնք առնվազն 90 օր է՝ առկա չեն։"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index 78b7ec744b..4181ebe13b 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": "次",
+ "HeaderLiveTV": "テレビ放送",
+ "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/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json
index 1f16a90842..72a521743e 100644
--- a/Emby.Server.Implementations/Localization/Core/ko.json
+++ b/Emby.Server.Implementations/Localization/Core/ko.json
@@ -111,5 +111,15 @@
"Original": "원본",
"NameExtraClip": "클립",
"NameExtraDeletedScene": "삭제된 장면",
- "NameExtraInterview": "인터뷰"
+ "NameExtraInterview": "인터뷰",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "샘플",
+ "NameExtraScene": "장면",
+ "NameExtraTrailer": "예고편",
+ "NameExtraUnknown": "부가영상",
+ "NameExtraThemeSong": "주제가",
+ "NameExtraBehindTheScenes": "비하인드 영상",
+ "NameExtraFeaturette": "피처렛",
+ "NameExtraShort": "단편",
+ "NameExtraThemeVideo": "테마 영상"
}
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/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json
index 735bc2c793..6da32f56a0 100644
--- a/Emby.Server.Implementations/Localization/Core/nb.json
+++ b/Emby.Server.Implementations/Localization/Core/nb.json
@@ -116,5 +116,8 @@
"NameExtraScene": "Scene",
"NameExtraThemeSong": "Tema-låt",
"NameExtraThemeVideo": "Tema-video",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "LyricDownloadFailureFromForItem": "Kunne ikke laste ned sangtekster fra {0} for {1}",
+ "NameExtraClip": "Klipp",
+ "NameExtraShort": "Kortfilm"
}
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/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..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..dd71f843e8 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -846,7 +846,13 @@ namespace Emby.Server.Implementations.Session
var data = _userDataManager.GetUserData(user, item);
data.PlayCount++;
- data.LastPlayedDate = DateTime.UtcNow;
+
+ // Re-watching a played item only counts once a progress or stop report gets past the resume threshold,
+ // otherwise rewatch Next Up moves on from an episode that was barely started
+ if (!data.Played || !item.SupportsPositionTicksResume)
+ {
+ data.LastPlayedDate = DateTime.UtcNow;
+ }
if (item.SupportsPlayedStatus && !item.SupportsPositionTicksResume)
{
@@ -972,7 +978,12 @@ namespace Emby.Server.Implementations.Session
if (positionTicks.HasValue)
{
- _userDataManager.UpdatePlayState(item, data, positionTicks.Value);
+ var playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value);
+ if (playedToCompletion || data.PlaybackPositionTicks > 0)
+ {
+ data.LastPlayedDate = DateTime.UtcNow;
+ }
+
changed = true;
}
@@ -1174,6 +1185,10 @@ namespace Emby.Server.Implementations.Session
if (positionTicks.HasValue)
{
playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value);
+ if (playedToCompletion || data.PlaybackPositionTicks > 0)
+ {
+ data.LastPlayedDate = DateTime.UtcNow;
+ }
}
else
{
@@ -1181,6 +1196,7 @@ namespace Emby.Server.Implementations.Session
data.PlayCount++;
data.Played = item.SupportsPlayedStatus;
data.PlaybackPositionTicks = 0;
+ data.LastPlayedDate = DateTime.UtcNow;
playedToCompletion = true;
}
@@ -1449,6 +1465,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 +1481,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)