aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs9
-rw-r--r--Emby.Naming/TV/EpisodePathParser.cs6
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs5
-rw-r--r--Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs65
-rw-r--r--Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs68
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs7
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs155
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs11
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchManager.cs14
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs42
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs103
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs41
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs129
-rw-r--r--Emby.Server.Implementations/Localization/Core/be.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/bs.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json13
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/lb.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json11
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/th.json41
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs4
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs11
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs97
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs18
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs13
-rw-r--r--Jellyfin.Api/Controllers/ArtistsController.cs64
-rw-r--r--Jellyfin.Api/Controllers/GenresController.cs9
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs27
-rw-r--r--Jellyfin.Api/Controllers/MusicGenresController.cs9
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs2
-rw-r--r--Jellyfin.Api/Controllers/SessionController.cs28
-rw-r--r--Jellyfin.Api/Controllers/StudiosController.cs9
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs2
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs1
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs4
-rw-r--r--Jellyfin.Data/Enums/VideoRangeType.cs5
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs167
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs20
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs6
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs71
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs29
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs14
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs92
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs146
-rw-r--r--Jellyfin.Server/Migrations/Stages/CodeMigration.cs75
-rw-r--r--Jellyfin.Server/Program.cs47
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupActivity.cs3
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogger.cs31
-rw-r--r--Jellyfin.sln7
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs14
-rw-r--r--MediaBrowser.Controller/IO/FileSystemHelper.cs36
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs28
-rw-r--r--MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs152
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj1
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs18
-rw-r--r--MediaBrowser.Controller/Persistence/IItemCountService.cs4
-rw-r--r--MediaBrowser.Controller/Providers/DirectoryService.cs111
-rw-r--r--MediaBrowser.Controller/Providers/IDirectoryService.cs13
-rw-r--r--MediaBrowser.Controller/Session/ISessionManager.cs12
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs2
-rw-r--r--MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs5
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs86
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs10
-rw-r--r--MediaBrowser.Model/Entities/MediaStream.cs30
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs8
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs47
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs89
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html28
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs10
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs8
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs131
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs7
-rw-r--r--MediaBrowser.Providers/TV/EpisodeMetadataService.cs25
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs7
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs67
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs6
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs38
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs20
-rw-r--r--src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs339
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs26
-rw-r--r--tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs210
-rw-r--r--tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs78
-rw-r--r--tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs216
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs162
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs40
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj26
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs99
-rw-r--r--tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs72
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs103
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs104
-rw-r--r--tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs129
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs3
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs126
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs140
-rw-r--r--tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs75
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs123
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs78
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs142
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs174
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs72
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs77
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs3
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs117
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs33
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs137
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs30
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs95
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs141
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs94
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs112
-rw-r--r--tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs54
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs21
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo43
124 files changed, 5686 insertions, 687 deletions
diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
index 8e7da5db42..1f16161282 100644
--- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs
+++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
@@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles
}
var extension = Path.GetExtension(path.AsSpan());
- if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
+
+ // .idx carries VobSub per-track language metadata. Recognize it here rather
+ // than adding it to NamingOptions.SubtitleFileExtensions, which also gates
+ // subtitle uploads/saves.
+ var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase);
+
+ if (!isVobSubIndex
+ && !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
{
diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs
index 0c737964b4..f06aa909ba 100644
--- a/Emby.Naming/TV/EpisodePathParser.cs
+++ b/Emby.Naming/TV/EpisodePathParser.cs
@@ -158,7 +158,9 @@ namespace Emby.Naming.TV
if (nextIndex >= name.Length
|| !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal))
{
- if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
+ // A range cannot end before it starts, so a lower number belongs to the episode title rather than to a range.
+ if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)
+ && num >= result.EpisodeNumber)
{
result.EndingEpisodeNumber = num;
}
@@ -226,7 +228,7 @@ namespace Emby.Naming.TV
info.SeriesName = result.SeriesName;
}
- if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue)
+ if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber)
{
info.EndingEpisodeNumber = result.EndingEpisodeNumber;
}
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 2462a754ae..a2d3e14439 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -192,7 +192,7 @@ namespace Emby.Server.Implementations.Dto
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
if (folderIds.Count > 0)
{
- childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id);
+ childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
}
}
@@ -700,7 +700,8 @@ namespace Emby.Server.Implementations.Dto
return count;
}
- // Fall back to individual query for special cases (Series, Season, etc.)
+ // Only reached when no batch was computed: the batch holds an entry for every folder it
+ // was asked about, zero included.
return folder.GetChildCount(user);
}
diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
index 933cfc8cbe..02b104756e 100644
--- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
+++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
@@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints;
/// </summary>
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
{
+ // A batch holds a live reference to every item it names, so it has to stay small enough that a
+ // library scan - which changes items faster than any batch window closes - cannot grow it without
+ // bound. Reached only by a scan; interactive use closes a batch on the window long before this.
+ internal const int MaxBatchSize = 2000;
+
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IProviderManager _providerManager;
@@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private readonly ILogger<LibraryChangedNotifier> _logger;
private readonly Lock _libraryChangedSyncLock = new();
- private readonly List<Folder> _foldersAddedTo = new();
- private readonly List<Folder> _foldersRemovedFrom = new();
- private readonly List<BaseItem> _itemsAdded = new();
- private readonly List<BaseItem> _itemsRemoved = new();
- private readonly List<BaseItem> _itemsUpdated = new();
+ private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
+ private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
+ private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
+ private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
+ private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
private Timer? _libraryUpdateTimer;
@@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
- private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList)
+ private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList)
{
if (!FilterItem(item))
{
@@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
lock (_libraryChangedSyncLock)
{
- var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
-
+ // The window runs from the first change of a batch and is never extended. Extending it on
+ // every change would keep a library scan's batch open for the whole scan, and the batch
+ // holds the items it names alive, so it would grow to the size of the library.
if (_libraryUpdateTimer is null)
{
+ var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
}
- else
- {
- _libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
- }
if (foldersList is not null && parent is Folder folder)
{
- foldersList.Add(folder);
+ foldersList[folder.Id] = folder;
}
- itemsList.Add(item);
+ itemsList[item.Id] = item;
+
+ // A window long enough to cover a burst still has to give way once the batch is large
+ // enough to be worth sending on its own.
+ if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize)
+ {
+ _libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
+ }
}
}
@@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
List<BaseItem> itemsRemoved;
lock (_libraryChangedSyncLock)
{
- // Remove dupes in case some were saved multiple times
- foldersAddedTo = _foldersAddedTo
- .DistinctBy(x => x.Id)
- .ToList();
-
- foldersRemovedFrom = _foldersRemovedFrom
- .DistinctBy(x => x.Id)
- .ToList();
+ foldersAddedTo = _foldersAddedTo.Values.ToList();
+ foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
itemsUpdated = _itemsUpdated
- .Where(i => !_itemsAdded.Contains(i))
- .DistinctBy(x => x.Id)
+ .Where(e => !_itemsAdded.ContainsKey(e.Key))
+ .Select(e => e.Value)
.ToList();
- itemsAdded = _itemsAdded.ToList();
- itemsRemoved = _itemsRemoved.ToList();
+ itemsAdded = _itemsAdded.Values.ToList();
+ itemsRemoved = _itemsRemoved.Values.ToList();
if (_libraryUpdateTimer is not null)
{
@@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
_foldersRemovedFrom.Clear();
}
+ if (itemsAdded.Count == 0
+ && itemsUpdated.Count == 0
+ && itemsRemoved.Count == 0
+ && foldersAddedTo.Count == 0
+ && foldersRemovedFrom.Count == 0)
+ {
+ return;
+ }
+
await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
}
diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
index fc174b7c14..b182e5837b 100644
--- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
+++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
@@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints
public sealed class UserDataChangeNotifier : IHostedService, IDisposable
{
private const int UpdateDuration = 500;
+ internal const int MaxBatchSize = 2000;
private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataManager;
private readonly IUserManager _userManager;
- private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new();
+ private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = [];
private readonly Lock _syncLock = new();
private Timer? _updateTimer;
+ private int _changedItemCount;
/// <summary>
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
@@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints
lock (_syncLock)
{
- if (_updateTimer is null)
+ // The window runs from the first change of a batch and is never extended, so a stream
+ // of changes that never pauses - a library scan - still closes its batches instead of
+ // holding every item it touched alive until the stream stops.
+ _updateTimer ??= new Timer(
+ UpdateTimerCallback,
+ null,
+ UpdateDuration,
+ Timeout.Infinite);
+
+ if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys))
{
- _updateTimer = new Timer(
- UpdateTimerCallback,
- null,
- UpdateDuration,
- Timeout.Infinite);
- }
- else
- {
- _updateTimer.Change(UpdateDuration, Timeout.Infinite);
- }
-
- if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys))
- {
- keys = new List<BaseItem>();
+ keys = [];
_changedItems[e.UserId] = keys;
}
- keys.Add(e.Item);
-
var baseItem = e.Item;
// Go up one level for indicators
if (baseItem is not null)
{
+ Track(keys, baseItem);
+
var parent = baseItem.GetOwner() ?? baseItem.GetParent();
if (parent is not null)
{
- keys.Add(parent);
+ Track(keys, parent);
}
}
+
+ // A window long enough to cover a burst still has to give way once the batch is
+ // large enough to be worth sending on its own.
+ if (_changedItemCount >= MaxBatchSize)
+ {
+ _updateTimer.Change(0, Timeout.Infinite);
+ }
+ }
+ }
+
+ private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item)
+ {
+ var before = keys.Count;
+ keys[item.Id] = item;
+
+ if (keys.Count != before)
+ {
+ _changedItemCount++;
}
}
private async void UpdateTimerCallback(object? state)
{
- List<KeyValuePair<Guid, List<BaseItem>>> changes;
+ List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes;
lock (_syncLock)
{
- // Remove dupes in case some were saved multiple times
changes = _changedItems.ToList();
_changedItems.Clear();
+ _changedItemCount = 0;
if (_updateTimer is not null)
{
@@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints
}
}
+ if (changes.Count == 0)
+ {
+ return;
+ }
+
foreach (var (userId, changedItems) in changes)
{
await _sessionManager.SendMessageToUserSessions(
[userId],
SessionMessageType.UserDataChanged,
- () => GetUserDataChangeInfo(userId, changedItems),
+ () => GetUserDataChangeInfo(userId, changedItems.Values),
default).ConfigureAwait(false);
}
}
- private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems)
+ private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems)
{
var user = _userManager.GetUserById(userId)
?? throw new ArgumentException("Invalid user ID", nameof(userId));
@@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints
{
UserId = userId,
UserDataList = changedItems
- .DistinctBy(x => x.Id)
.Select(i =>
{
var dto = _userDataManager.GetUserDataDto(i, user);
diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs
index 1bf0f8c76c..0f92e2f03e 100644
--- a/Emby.Server.Implementations/IO/LibraryMonitor.cs
+++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs
@@ -8,6 +8,7 @@ using Emby.Server.Implementations.Library;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -21,6 +22,7 @@ namespace Emby.Server.Implementations.IO
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
+ private readonly IDirectoryService _directoryService;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
/// <summary>
@@ -47,6 +49,7 @@ namespace Emby.Server.Implementations.IO
/// <param name="libraryManager">The library manager.</param>
/// <param name="configurationManager">The configuration manager.</param>
/// <param name="fileSystem">The filesystem.</param>
+ /// <param name="directoryService">The directory service.</param>
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
public LibraryMonitor(
@@ -54,6 +57,7 @@ namespace Emby.Server.Implementations.IO
ILibraryManager libraryManager,
IServerConfigurationManager configurationManager,
IFileSystem fileSystem,
+ IDirectoryService directoryService,
IHostApplicationLifetime appLifetime,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
{
@@ -61,6 +65,7 @@ namespace Emby.Server.Implementations.IO
_logger = logger;
_configurationManager = configurationManager;
_fileSystem = fileSystem;
+ _directoryService = directoryService;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
appLifetime.ApplicationStarted.Register(Start);
@@ -363,6 +368,8 @@ namespace Emby.Server.Implementations.IO
return;
}
+ _directoryService.Invalidate(path);
+
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
foreach (var i in _tempIgnoredPaths.Keys)
{
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index dd8c883684..e6fa94fbef 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -15,7 +16,6 @@ using Emby.Naming.Common;
using Emby.Naming.TV;
using Emby.Naming.Video;
using Emby.Server.Implementations.Library.Resolvers;
-using Emby.Server.Implementations.Library.Validators;
using Emby.Server.Implementations.Playlists;
using Emby.Server.Implementations.ScheduledTasks.Tasks;
using Emby.Server.Implementations.Sorting;
@@ -35,7 +35,6 @@ using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
@@ -75,7 +74,6 @@ namespace Emby.Server.Implementations.Library
private readonly Lazy<IProviderManager> _providerManagerFactory;
private readonly Lazy<IUserViewManager> _userViewManagerFactory;
private readonly IServerApplicationHost _appHost;
- private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
private readonly IItemRepository _itemRepository;
private readonly IItemPersistenceService _persistenceService;
@@ -88,6 +86,7 @@ namespace Emby.Server.Implementations.Library
private readonly ExtraResolver _extraResolver;
private readonly IPathManager _pathManager;
private readonly ILocalizationManager _localization;
+ private readonly IDirectoryService _directoryService;
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository;
@@ -122,7 +121,6 @@ namespace Emby.Server.Implementations.Library
/// <param name="fileSystem">The file system.</param>
/// <param name="providerManagerFactory">The provider manager.</param>
/// <param name="userViewManagerFactory">The user view manager.</param>
- /// <param name="mediaEncoder">The media encoder.</param>
/// <param name="itemRepository">The item repository.</param>
/// <param name="persistenceService">The item persistence service.</param>
/// <param name="nextUpService">The next up service.</param>
@@ -148,7 +146,6 @@ namespace Emby.Server.Implementations.Library
IFileSystem fileSystem,
Lazy<IProviderManager> providerManagerFactory,
Lazy<IUserViewManager> userViewManagerFactory,
- IMediaEncoder mediaEncoder,
IItemRepository itemRepository,
IItemPersistenceService persistenceService,
INextUpService nextUpService,
@@ -174,7 +171,6 @@ namespace Emby.Server.Implementations.Library
_fileSystem = fileSystem;
_providerManagerFactory = providerManagerFactory;
_userViewManagerFactory = userViewManagerFactory;
- _mediaEncoder = mediaEncoder;
_itemRepository = itemRepository;
_persistenceService = persistenceService;
_nextUpService = nextUpService;
@@ -189,6 +185,7 @@ namespace Emby.Server.Implementations.Library
_pathManager = pathManager;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
_localization = localization;
+ _directoryService = directoryService;
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
@@ -1210,6 +1207,12 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
+ public Guid GetPersonId(string name)
+ {
+ return GetItemByNameId<Person>(Person.GetPath(name));
+ }
+
+ /// <inheritdoc />
public Person? GetPerson(string name)
{
var path = Person.GetPath(name);
@@ -1222,6 +1225,33 @@ namespace Emby.Server.Implementations.Library
return null;
}
+ /// <inheritdoc />
+ public Person GetOrCreatePerson(string name)
+ {
+ var existing = GetPerson(name);
+ if (existing is not null)
+ {
+ return existing;
+ }
+
+ var path = Person.GetPath(name);
+ var info = Directory.CreateDirectory(path);
+ var item = new Person
+ {
+ Name = name,
+ Id = GetItemByNameId<Person>(path),
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc,
+ Path = path
+ };
+
+ item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
+
+ CreateItem(item, null);
+
+ return item;
+ }
+
/// <summary>
/// Gets the studio.
/// </summary>
@@ -1354,15 +1384,6 @@ namespace Emby.Server.Implementations.Library
return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId);
}
- /// <inheritdoc />
- public Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken)
- {
- // Ensure the location is available.
- Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath);
-
- return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress);
- }
-
/// <summary>
/// Reloads the root media folder.
/// </summary>
@@ -1489,6 +1510,10 @@ namespace Emby.Server.Implementations.Library
var numComplete = 0;
var numTasks = tasks.Count;
+ _logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks);
+
+ var phaseStart = Stopwatch.GetTimestamp();
+
foreach (var task in tasks)
{
// Prevent access to modified closure
@@ -1506,20 +1531,45 @@ namespace Emby.Server.Implementations.Library
progress.Report(innerPercent);
});
- _logger.LogDebug("Running post-scan task {0}", task.GetType().Name);
+ var taskName = task.GetType().Name;
+ var taskStart = Stopwatch.GetTimestamp();
+
+ _logger.LogInformation(
+ "Running post-scan task {TaskNumber}/{TaskCount}: {TaskName}",
+ currentNumComplete + 1,
+ numTasks,
+ taskName);
try
{
await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
+
+ var elapsed = Stopwatch.GetElapsedTime(taskStart);
+ _logger.LogInformation(
+ "Post-scan task {TaskName} completed after {Minutes} minute(s) and {Seconds} seconds",
+ taskName,
+ Math.Truncate(elapsed.TotalMinutes),
+ elapsed.Seconds);
}
catch (OperationCanceledException)
{
- _logger.LogInformation("Post-scan task cancelled: {0}", task.GetType().Name);
+ var elapsed = Stopwatch.GetElapsedTime(taskStart);
+ _logger.LogInformation(
+ "Post-scan task {TaskName} cancelled after {Minutes} minute(s) and {Seconds} seconds",
+ taskName,
+ Math.Truncate(elapsed.TotalMinutes),
+ elapsed.Seconds);
throw;
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error running post-scan task");
+ var elapsed = Stopwatch.GetElapsedTime(taskStart);
+ _logger.LogError(
+ ex,
+ "Post-scan task {TaskName} failed after {Minutes} minute(s) and {Seconds} seconds",
+ taskName,
+ Math.Truncate(elapsed.TotalMinutes),
+ elapsed.Seconds);
}
numComplete++;
@@ -1528,6 +1578,12 @@ namespace Emby.Server.Implementations.Library
progress.Report(percent * 100);
}
+ var phaseElapsed = Stopwatch.GetElapsedTime(phaseStart);
+ _logger.LogInformation(
+ "All post-scan tasks completed after {Minutes} minute(s) and {Seconds} seconds",
+ Math.Truncate(phaseElapsed.TotalMinutes),
+ phaseElapsed.Seconds);
+
_persistenceService.UpdateInheritedValues();
progress.Report(100);
@@ -1745,9 +1801,9 @@ namespace Emby.Server.Implementations.Library
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
}
- public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
+ public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
{
- return _countService.GetChildCountBatch(parentIds, userId);
+ return _countService.GetChildCountBatch(parentIds, user);
}
/// <inheritdoc/>
@@ -1984,18 +2040,10 @@ namespace Emby.Server.Implementations.Library
{
// Playlists and BoxSets store their contents in LinkedChildren and never
// populate AncestorIds for those items, so a recursive AncestorIds query
- // would return zero rows. Resolve to the linked child IDs up front and
- // route through the existing indexed ItemIds filter.
- query.ItemIds = folder.LinkedChildren
- .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
- .Select(lc => lc.ItemId!.Value)
- .ToArray();
-
- // Empty linked-children should still return empty rather than scanning everything.
- if (query.ItemIds.Length == 0)
- {
- query.ItemIds = [Guid.NewGuid()];
- }
+ // would return zero rows. Filter by the descendant set instead, which follows
+ // the links and keeps descending, so a linked folder contributes what is below
+ // it as well - the episodes of a Series added to a collection, for example.
+ query.DescendantOfId = folder.Id;
}
else
{
@@ -3728,6 +3776,10 @@ namespace Emby.Server.Implementations.Library
AddMediaPathInternal(name, path, false);
}
}
+
+ // The libraries root was listed before this folder existed, so drop that listing:
+ // anything still reading it resolves the library set without the new folder.
+ _directoryService.Invalidate(virtualFolderPath);
}
finally
{
@@ -3754,27 +3806,14 @@ namespace Emby.Server.Implementations.Library
var itemUpdateType = ItemUpdateType.MetadataDownload;
var saveEntity = false;
- var createEntity = false;
var personEntity = GetPerson(person.Name);
if (personEntity is null)
{
try
{
- var path = Person.GetPath(person.Name);
- var info = Directory.CreateDirectory(path);
- personEntity = new Person()
- {
- Name = person.Name,
- Id = GetItemByNameId<Person>(path),
- DateCreated = info.CreationTimeUtc,
- DateModified = info.LastWriteTimeUtc,
- Path = path
- };
-
- personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey();
+ personEntity = GetOrCreatePerson(person.Name);
saveEntity = true;
- createEntity = true;
}
catch (Exception ex)
{
@@ -3808,11 +3847,6 @@ namespace Emby.Server.Implementations.Library
if (saveEntity)
{
- if (createEntity)
- {
- CreateItems([personEntity], null, CancellationToken.None);
- }
-
await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
personEntity.DateLastSaved = DateTime.UtcNow;
@@ -3852,7 +3886,9 @@ namespace Emby.Server.Implementations.Library
}
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
- var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+ var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
+ ?? throw new FileNotFoundException(
+ string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
CreateShortcut(virtualFolderPath, pathInfo);
@@ -3873,7 +3909,9 @@ namespace Emby.Server.Implementations.Library
ArgumentNullException.ThrowIfNull(mediaPath);
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
- var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+ var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
+ ?? throw new FileNotFoundException(
+ string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
@@ -3912,9 +3950,9 @@ namespace Emby.Server.Implementations.Library
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
- var path = Path.Combine(rootFolderPath, name);
+ var path = FileSystemHelper.GetChildPath(rootFolderPath, name);
- if (!Directory.Exists(path))
+ if (path is null || !Directory.Exists(path))
{
throw new FileNotFoundException("The media folder does not exist");
}
@@ -3924,6 +3962,7 @@ namespace Emby.Server.Implementations.Library
try
{
Directory.Delete(path, true);
+ _directoryService.Invalidate(path);
}
finally
{
@@ -3978,9 +4017,9 @@ namespace Emby.Server.Implementations.Library
ArgumentException.ThrowIfNullOrEmpty(mediaPath);
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
- var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+ var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName);
- if (!Directory.Exists(virtualFolderPath))
+ if (virtualFolderPath is null || !Directory.Exists(virtualFolderPath))
{
throw new FileNotFoundException(
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
@@ -3993,6 +4032,7 @@ namespace Emby.Server.Implementations.Library
if (!string.IsNullOrEmpty(shortcut))
{
_fileSystem.DeleteFile(shortcut);
+ _directoryService.Invalidate(shortcut);
}
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
@@ -4036,6 +4076,7 @@ namespace Emby.Server.Implementations.Library
}
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
+ _directoryService.Invalidate(lnk);
RemoveContentTypeOverrides(path);
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
index 6624d0125f..a8bd832cc8 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
@@ -129,6 +129,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
var tmdbId = justName.GetAttributeValue("tmdbid");
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
+
+ // Anime databases model a single cour as its own entry, so a multi-season
+ // series maps to one of these ids per season rather than one per series.
+ var anidbId = justName.GetAttributeValue("anidbid");
+ item.TrySetProviderId("AniDB", anidbId);
+
+ var aniListId = justName.GetAttributeValue("anilistid");
+ item.TrySetProviderId("AniList", aniListId);
+
+ var aniSearchId = justName.GetAttributeValue("anisearchid");
+ item.TrySetProviderId("AniSearch", aniSearchId);
}
}
}
diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs
index 0e180753a6..306a8673d5 100644
--- a/Emby.Server.Implementations/Library/Search/SearchManager.cs
+++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs
@@ -112,13 +112,12 @@ public class SearchManager : ISearchManager
return externalResults;
}
- var internalResults = await internalTask.ConfigureAwait(false);
if (_internalProviders.Length > 0)
{
_logger.LogDebug("No results from external providers, using internal provider results");
}
- return internalResults;
+ return await internalTask.ConfigureAwait(false);
}
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
@@ -144,17 +143,16 @@ public class SearchManager : ISearchManager
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
- var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
- if (allowedCount == candidates.Count)
- {
- return candidates;
- }
-
var allowedIds = await baseQuery
.Select(e => e.Id)
.ToHashSetAsync(cancellationToken)
.ConfigureAwait(false);
+ if (allowedIds.Count == candidates.Count)
+ {
+ return candidates;
+ }
+
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
if (filtered.Count < candidates.Count)
{
diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs
new file mode 100644
index 0000000000..75aea0eab6
--- /dev/null
+++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs
@@ -0,0 +1,42 @@
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+
+namespace Emby.Server.Implementations.Library.SimilarItems;
+
+/// <summary>
+/// Builds the access filter that decides which items a similar-items lookup may return for a user.
+/// </summary>
+internal static class SimilarItemsAccessFilter
+{
+ private static readonly BaseItemKind[] _itemByNameKinds =
+ [
+ BaseItemKind.Person,
+ BaseItemKind.Genre,
+ BaseItemKind.MusicGenre,
+ BaseItemKind.MusicArtist,
+ BaseItemKind.Studio
+ ];
+
+ /// <summary>
+ /// Builds an access filter carrying the user's library access and parental restrictions.
+ /// </summary>
+ /// <param name="user">The user the lookup runs for.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <returns>The access filter.</returns>
+ public static InternalItemsQuery Build(User user, ILibraryManager libraryManager)
+ {
+ // IncludeItemTypes is read only for the by-name exemption here; the caller applies this
+ // filter through ApplyAccessFiltering, which does not translate it into a type restriction.
+ var accessFilter = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = _itemByNameKinds
+ };
+
+ // ConfigureUserAccess populates TopParentIds for the libraries the user may open.
+ libraryManager.ConfigureUserAccess(accessFilter, user);
+
+ return accessFilter;
+ }
+}
diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
index 4e482c174a..fd5f292ebe 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
@@ -7,8 +7,10 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
@@ -16,11 +18,13 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.SimilarItems;
@@ -35,6 +39,8 @@ public class SimilarItemsManager : ISimilarItemsManager
private readonly ILibraryManager _libraryManager;
private readonly IFileSystem _fileSystem;
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IItemQueryHelpers _queryHelpers;
private ISimilarItemsProvider[] _similarItemsProviders = [];
/// <summary>
@@ -45,18 +51,24 @@ public class SimilarItemsManager : ISimilarItemsManager
/// <param name="libraryManager">The library manager.</param>
/// <param name="fileSystem">The file system.</param>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
+ /// <param name="dbProvider">The database context factory.</param>
+ /// <param name="queryHelpers">The shared item query helpers.</param>
public SimilarItemsManager(
ILogger<SimilarItemsManager> logger,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
IFileSystem fileSystem,
- IServerConfigurationManager serverConfigurationManager)
+ IServerConfigurationManager serverConfigurationManager,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IItemQueryHelpers queryHelpers)
{
_logger = logger;
_appPaths = appPaths;
_libraryManager = libraryManager;
_fileSystem = fileSystem;
_serverConfigurationManager = serverConfigurationManager;
+ _dbProvider = dbProvider;
+ _queryHelpers = queryHelpers;
}
/// <inheritdoc/>
@@ -230,11 +242,64 @@ public class SimilarItemsManager : ISimilarItemsManager
}
}
- return allResults
+ var ordered = allResults
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.Take(requestedLimit)
.ToList();
+
+ return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync(
+ IReadOnlyList<BaseItem> candidates,
+ User? user,
+ CancellationToken cancellationToken)
+ {
+ if (candidates.Count == 0 || user is null)
+ {
+ return candidates;
+ }
+
+ var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager);
+
+ // No accessible libraries means nothing to compare against, and an empty TopParentIds set
+ // would disable the filter rather than reject everything.
+ if (accessFilter.TopParentIds.Length == 0)
+ {
+ return candidates;
+ }
+
+ Guid[] candidateIds = [.. candidates.Select(c => c.Id)];
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var baseQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(candidateIds, e => e.Id);
+
+ baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
+
+ var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
+ if (allowedCount == candidates.Count)
+ {
+ return candidates;
+ }
+
+ var allowedIds = await baseQuery
+ .Select(e => e.Id)
+ .ToHashSetAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var filtered = candidates.Where(c => allowedIds.Contains(c.Id)).ToList();
+ _logger.LogDebug(
+ "Dropped {Dropped} of {Total} similar-item candidates due to user access filtering",
+ candidates.Count - filtered.Count,
+ candidates.Count);
+
+ return filtered;
+ }
}
/// <inheritdoc/>
@@ -376,19 +441,39 @@ public class SimilarItemsManager : ISimilarItemsManager
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
+ // Filter once across every category rather than per baseline, so a batch provider costs one
+ // access query no matter how many categories it produced.
+ var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList();
+ var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false);
+
+ HashSet<Guid>? allowedIds = allowed.Count == allItems.Count
+ ? null
+ : [.. allowed.Select(item => item.Id)];
+
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
foreach (var baseline in baselineItems)
{
- if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
+ if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0)
+ {
+ continue;
+ }
+
+ if (allowedIds is not null)
{
- recommendations.Add(new SimilarItemsRecommendation
+ similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList();
+ if (similar.Count == 0)
{
- BaselineItemName = baseline.Name,
- CategoryId = baseline.Id,
- RecommendationType = recommendationType,
- Items = similar
- });
+ continue;
+ }
}
+
+ recommendations.Add(new SimilarItemsRecommendation
+ {
+ BaselineItemName = baseline.Name,
+ CategoryId = baseline.Id,
+ RecommendationType = recommendationType,
+ Items = similar
+ });
}
return recommendations;
diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
index fa7112eb90..690466be70 100644
--- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
@@ -61,6 +62,9 @@ public class ArtistsValidator
var count = names.Count;
var refreshed = 0;
+ var liveIds = new HashSet<Guid>();
+ var unresolved = 0;
+
foreach (var name in names)
{
try
@@ -73,13 +77,20 @@ public class ArtistsValidator
// Fall back to GetArtist if not found (creates new item if needed)
item ??= _libraryManager.GetArtist(name);
- var isNew = !existingArtistIds.Contains(item.Id);
- var neverRefreshed = item.DateLastRefreshed == default;
- if (isNew || neverRefreshed)
+ // A name with no item is nothing to refresh, and nothing to keep alive either.
+ if (item is not null)
{
- await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
- refreshed++;
+ liveIds.Add(item.Id);
+
+ var isNew = !existingArtistIds.Contains(item.Id);
+ var neverRefreshed = item.DateLastRefreshed == default;
+
+ if (isNew || neverRefreshed)
+ {
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ refreshed++;
+ }
}
}
catch (OperationCanceledException)
@@ -88,6 +99,7 @@ public class ArtistsValidator
}
catch (Exception ex)
{
+ unresolved++;
_logger.LogError(ex, "Error refreshing {ArtistName}", name);
}
@@ -101,13 +113,26 @@ public class ArtistsValidator
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count);
+ // Every name that threw is a name whose artist is missing from the live set, and deleting against
+ // a live set with holes in it deletes artists the library still refers to. Leave the sweep to a
+ // run that got a clean read of them.
+ if (unresolved > 0)
+ {
+ _logger.LogWarning(
+ "Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run",
+ unresolved,
+ count);
+
+ progress.Report(100);
+ return;
+ }
+
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.MusicArtist],
- IsDeadArtist = true,
IsLocked = false
- }).Cast<MusicArtist>()
- .Where(item => item.IsAccessedByName)
+ }).OfType<MusicArtist>()
+ .Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id))
.ToList();
foreach (var item in deadEntities)
diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
index 078a0b921d..7d53f40ce7 100644
--- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
@@ -1,12 +1,12 @@
using System;
+using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.Validators;
@@ -17,112 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators;
public class PeopleValidator
{
/// <summary>
- /// The _library manager.
+ /// The library manager.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
- /// The _logger.
+ /// The logger.
/// </summary>
- private readonly ILogger _logger;
-
- private readonly IFileSystem _fileSystem;
+ private readonly ILogger<PeopleValidator> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PeopleValidator" /> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param>
- /// <param name="fileSystem">The file system.</param>
- public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem)
+ public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger)
{
_libraryManager = libraryManager;
_logger = logger;
- _fileSystem = fileSystem;
}
/// <summary>
/// Validates the people.
/// </summary>
- /// <param name="cancellationToken">The cancellation token.</param>
/// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{
// Before the refresh below walks them: a credit no item maps to any more stands for nothing,
// and while it is there the person it names cannot reach the dead-person sweep either.
var numOrphaned = _libraryManager.DeleteOrphanedCredits();
if (numOrphaned > 0)
{
- _logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
+ _logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned);
}
- var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
-
- var numComplete = 0;
-
- var numPeople = people.Count;
+ var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
+ var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Person]
+ }).ToHashSet();
- IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
+ var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds);
- _logger.LogDebug("Will refresh {Amount} people", numPeople);
+ var numComplete = 0;
+ var count = names.Count;
+ var refreshed = 0;
- foreach (var person in people)
+ foreach (var name in names)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
- var item = _libraryManager.GetPerson(person);
- if (item is null)
- {
- _logger.LogWarning("Failed to get person: {Name}", person);
- continue;
- }
+ var item = _libraryManager.GetOrCreatePerson(name);
+ var isNew = !existingPersonIds.Contains(item.Id);
+ var neverRefreshed = item.DateLastRefreshed == default;
- var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ if (isNew || neverRefreshed)
{
- ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
- MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
- };
-
- await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ refreshed++;
+ }
}
catch (OperationCanceledException)
{
+ // Don't clutter the log
throw;
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error validating IBN entry {Person}", person);
+ _logger.LogError(ex, "Error refreshing {PersonName}", name);
}
- // Update progress
numComplete++;
double percent = numComplete;
- percent /= numPeople;
+ percent /= count;
+ percent *= 100;
- subProgress.Report(100 * percent);
+ progress.Report(percent);
}
- var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = [BaseItemKind.Person],
- IsDeadPerson = true,
- IsLocked = false
- });
+ _logger.LogInformation(
+ "Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet",
+ refreshed,
+ count,
+ newNames.Count);
- subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
+ // A person somebody locked is theirs, not ours, however little the library still credits them.
+ var deadEntities = deadIds
+ .Select(_libraryManager.GetItemById)
+ .OfType<Person>()
+ .Where(item => !item.IsLocked)
+ .ToList();
- var i = 0;
- foreach (var item in deadEntities.Chunk(500))
+ foreach (var item in deadEntities)
{
- _libraryManager.DeleteItemsUnsafeFast(item, true);
- subProgress.Report(100f / deadEntities.Count * (i++ * 100));
+ _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
}
+ _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
+
progress.Report(100);
+ }
+
+ /// <summary>
+ /// Splits the person items into the ones a credit still calls for and the ones nothing does.
+ /// </summary>
+ /// <param name="creditNames">Every name credited on an item, from the people table.</param>
+ /// <param name="getPersonId">Maps a credit name to the id its person item has.</param>
+ /// <param name="existingPersonIds">The ids of the person items that exist.</param>
+ /// <returns>The credits needing an item, and the ids of the items nothing credits.</returns>
+ internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId(
+ IReadOnlyList<string> creditNames,
+ Func<string, Guid> getPersonId,
+ IReadOnlySet<Guid> existingPersonIds)
+ {
+ ArgumentNullException.ThrowIfNull(creditNames);
+ ArgumentNullException.ThrowIfNull(getPersonId);
+ ArgumentNullException.ThrowIfNull(existingPersonIds);
+
+ var newNames = new List<string>();
+ var liveIds = new HashSet<Guid>();
+
+ foreach (var name in creditNames)
+ {
+ var personId = getPersonId(name);
+
+ // Distinct credit names can normalize onto one id; only the first of them needs an item.
+ if (liveIds.Add(personId) && !existingPersonIds.Contains(personId))
+ {
+ newNames.Add(name);
+ }
+ }
+
+ var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList();
- _logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned);
+ return (newNames, deadIds);
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json
index 49ebc45f06..c5b1213096 100644
--- a/Emby.Server.Implementations/Localization/Core/be.json
+++ b/Emby.Server.Implementations/Localization/Core/be.json
@@ -112,5 +112,11 @@
"NameExtraInterview": "Інтэрв'ю",
"NameExtraNumbered": "{0} {1}",
"NameExtraScene": "Сцэна",
- "NameExtraTrailer": "Трэйлер"
+ "NameExtraTrailer": "Трэйлер",
+ "NameExtraBehindTheScenes": "За кулісамі",
+ "NameExtraClip": "Кліп",
+ "NameExtraFeaturette": "Кароткаметражка",
+ "NameExtraSample": "Прыклад",
+ "NameExtraShort": "Кароткаметражка",
+ "NameExtraThemeSong": "Тэматычная песня"
}
diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json
index aa7fe4eb24..5686807d9a 100644
--- a/Emby.Server.Implementations/Localization/Core/bs.json
+++ b/Emby.Server.Implementations/Localization/Core/bs.json
@@ -106,5 +106,17 @@
"TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay",
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.",
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
- "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana."
+ "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana.",
+ "NameExtraBehindTheScenes": "Iza kulisa",
+ "NameExtraClip": "Isječak",
+ "NameExtraDeletedScene": "Izbrišana scena",
+ "NameExtraFeaturette": "Kratki prilog",
+ "NameExtraInterview": "Intervju",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Uzorak",
+ "NameExtraScene": "Scena",
+ "NameExtraShort": "Kratko",
+ "NameExtraThemeSong": "Tema",
+ "NameExtraThemeVideo": "Tematski video",
+ "NameExtraTrailer": "Najava"
}
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index c0ad2c165a..610bc286b8 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -108,5 +108,16 @@
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
- "Original": "Πρωτότυπο"
+ "Original": "Πρωτότυπο",
+ "NameExtraBehindTheScenes": "Πίσω από τις Σκηνές",
+ "NameExtraDeletedScene": "Διεγραμμένη Σκηνή",
+ "NameExtraFeaturette": "Πρόσθετα βίντεο",
+ "NameExtraInterview": "Συνέντευξη",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Δείγμα",
+ "NameExtraScene": "Σκηνή",
+ "NameExtraShort": "Βίντεο μικρού μήκους",
+ "NameExtraThemeSong": "Θεματικό Τραγούδι",
+ "NameExtraThemeVideo": "Θεματικό Βίντεο",
+ "NameExtraTrailer": "τρέιλερ ταινίας"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index 36a248a1d1..9a453120dd 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -113,5 +113,12 @@
"NameExtraClip": "Klippi",
"NameExtraDeletedScene": "Poistettu Kohtaus",
"NameExtraFeaturette": "Lyhytelokuva",
- "NameExtraInterview": "Haastattelu"
+ "NameExtraInterview": "Haastattelu",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Näyte",
+ "NameExtraScene": "Kohtaus",
+ "NameExtraShort": "Lyhytfilmi",
+ "NameExtraThemeSong": "Tunnusmusiikki",
+ "NameExtraThemeVideo": "Tunnusvideo",
+ "NameExtraTrailer": "Traileri"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 6aa72908cb..bd15bac865 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -8,7 +8,7 @@
"Books": "Bøkur",
"ChapterNameValue": "Kapittul {0}",
"Favorites": "Yndis",
- "Folders": "Mappur",
+ "Folders": "Skjáttur",
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
@@ -104,7 +104,7 @@
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
"NameExtraShort": "Stuttfilmur",
"NameExtraThemeSong": "Eyðkennislag",
- "NameExtraTrailer": "Forfilmur",
+ "NameExtraTrailer": "Brellbiti",
"NameExtraInterview": "Samrøða",
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
"NameExtraClip": "Klipp",
@@ -118,8 +118,8 @@
"TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað",
"TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.",
"NameExtraThemeVideo": "Eyðkenniskykmynd",
- "NameExtraDeletedScene": "Úrtikin mynd (scena)",
- "NameExtraScene": "Mynd (scena)",
+ "NameExtraDeletedScene": "Úrtikin mynd",
+ "NameExtraScene": "Mynd",
"NameExtraUnknown": "Eykatilfar",
"Original": "Upprunalig(t/ur)"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json
index 1ee606cc64..30e11d15f0 100644
--- a/Emby.Server.Implementations/Localization/Core/ga.json
+++ b/Emby.Server.Implementations/Localization/Core/ga.json
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora",
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.",
"Original": "Bunaidh",
- "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}"
+ "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}",
+ "NameExtraBehindTheScenes": "Taobh thiar de na Radhairc",
+ "NameExtraClip": "Gearrthóg",
+ "NameExtraDeletedScene": "Radharc Scriosta",
+ "NameExtraFeaturette": "Mionghné",
+ "NameExtraInterview": "Agallamh",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Sampla",
+ "NameExtraScene": "Radharc",
+ "NameExtraShort": "Gearr",
+ "NameExtraThemeSong": "Amhrán Téama",
+ "NameExtraThemeVideo": "Físeán Téama",
+ "NameExtraTrailer": "Leantóir"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index 442c26b30b..2d38c173f0 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
"Original": "Original",
- "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
+ "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo",
+ "NameExtraBehindTheScenes": "Iza kulisa",
+ "NameExtraClip": "Klip",
+ "NameExtraDeletedScene": "Obrisana Scena",
+ "NameExtraFeaturette": "Promotivni video",
+ "NameExtraInterview": "Intervju",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Uzorak",
+ "NameExtraScene": "Scena",
+ "NameExtraShort": "Kratki film",
+ "NameExtraThemeSong": "Glavna Pjesma",
+ "NameExtraThemeVideo": "Tema videa",
+ "NameExtraTrailer": "Trailer"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json
index 917f26a49c..21d31c5fbf 100644
--- a/Emby.Server.Implementations/Localization/Core/lb.json
+++ b/Emby.Server.Implementations/Localization/Core/lb.json
@@ -108,5 +108,17 @@
"LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}",
"Original": "Original",
"CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten",
- "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn."
+ "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.",
+ "NameExtraBehindTheScenes": "Hannert de Kulissen",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Geläschte Scène",
+ "NameExtraFeaturette": "Featurette",
+ "NameExtraInterview": "Interview",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Beispill",
+ "NameExtraScene": "Scène",
+ "NameExtraShort": "Kuerzfilm",
+ "NameExtraThemeSong": "Theme-Lidd",
+ "NameExtraThemeVideo": "Theme-Video",
+ "NameExtraTrailer": "Bande-Annonce"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index dbfeabd88e..c41cedf98a 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -100,14 +100,14 @@
"TaskAudioNormalization": "Garso normalizavimas",
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
- "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
+ "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus",
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
- "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
+ "TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus",
"CleanupUserDataTask": "Naudotojo duomenų valymo užduotis",
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).",
- "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
+ "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}",
"NameExtraBehindTheScenes": "Užkulisiuose",
"NameExtraClip": "Klipas",
"NameExtraDeletedScene": "Ištrinta scena",
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 76fa9e3cf7..52f1eecbe4 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
"Original": "Oriģināls",
- "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
+ "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}",
+ "NameExtraBehindTheScenes": "Aiz kadra",
+ "NameExtraClip": "Klips",
+ "NameExtraDeletedScene": "Izdzēsta aina",
+ "NameExtraFeaturette": "Īsfilma",
+ "NameExtraInterview": "Intervija",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Paraugs",
+ "NameExtraScene": "Aina",
+ "NameExtraShort": "Īsfilma",
+ "NameExtraThemeSong": "Motīvu dziesma",
+ "NameExtraThemeVideo": "Tēmas video",
+ "NameExtraTrailer": "Treileris"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json
index 752b74ec1c..735bc2c793 100644
--- a/Emby.Server.Implementations/Localization/Core/nb.json
+++ b/Emby.Server.Implementations/Localization/Core/nb.json
@@ -106,5 +106,15 @@
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.",
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.",
"CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.",
- "CleanupUserDataTask": "Oppgave for opprydding av brukerdata"
+ "CleanupUserDataTask": "Oppgave for opprydding av brukerdata",
+ "NameExtraBehindTheScenes": "Bak kulissene",
+ "NameExtraDeletedScene": "Slettet scene",
+ "NameExtraFeaturette": "Presentasjonsfilm",
+ "NameExtraInterview": "Intervju",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Prøve",
+ "NameExtraScene": "Scene",
+ "NameExtraThemeSong": "Tema-låt",
+ "NameExtraThemeVideo": "Tema-video",
+ "NameExtraTrailer": "Trailer"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index 358c19881f..dccec8067d 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -111,5 +111,14 @@
"Original": "Original",
"NameExtraBehindTheScenes": "În culise",
"NameExtraClip": "Clip",
- "NameExtraDeletedScene": "Scenă ștearsă"
+ "NameExtraDeletedScene": "Scenă ștearsă",
+ "NameExtraFeaturette": "Material bonus",
+ "NameExtraInterview": "Interviu",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Monstră",
+ "NameExtraScene": "Scenă",
+ "NameExtraShort": "Scurt",
+ "NameExtraThemeSong": "Audio de Fundal",
+ "NameExtraThemeVideo": "Video de Fundal",
+ "NameExtraTrailer": "Trailer"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
index a1b5b714af..6ea625d66c 100644
--- a/Emby.Server.Implementations/Localization/Core/sl-SI.json
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
"CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.",
"LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
- "Original": "Original"
+ "Original": "Original",
+ "NameExtraBehindTheScenes": "V zakulisju",
+ "NameExtraClip": "Klip",
+ "NameExtraDeletedScene": "Izbrisan prizor",
+ "NameExtraFeaturette": "Kratek dokumentarec o izdelavi filma",
+ "NameExtraInterview": "Intervju",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Vzorec",
+ "NameExtraScene": "Prizor",
+ "NameExtraShort": "Kratki film",
+ "NameExtraThemeSong": "Tematska Pesem",
+ "NameExtraThemeVideo": "Tematski Video",
+ "NameExtraTrailer": "Napovednik"
}
diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json
index 716e3ae55d..77e526db74 100644
--- a/Emby.Server.Implementations/Localization/Core/th.json
+++ b/Emby.Server.Implementations/Localization/Core/th.json
@@ -22,20 +22,20 @@
"NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว",
"NameSeasonUnknown": "ไม่ทราบซีซัน",
"NameSeasonNumber": "ซีซัน {0}",
- "NameInstallFailed": "การติดตั้ง {0} ล้มเหลว",
+ "NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ",
"MusicVideos": "มิวสิควิดีโอ",
- "Music": "ดนตรี",
+ "Music": "เพลง",
"Movies": "ภาพยนตร์",
- "MixedContent": "เนื้อหาผสม",
- "Latest": "ล่าสุด",
- "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}",
- "LabelIpAddressValue": "ที่อยู่ IP: {0}",
- "Inherit": "สืบทอด",
- "HomeVideos": "โฮมวิดีโอ",
- "HeaderNextUp": "ถัดไป",
- "HeaderLiveTV": "ทีวีสด",
- "HeaderFavoriteShows": "รายการที่ชื่นชอบ",
- "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ",
+ "MixedContent": "เนื้อหาหลากหลายประเภท",
+ "Latest": "มาใหม่ล่าสุด",
+ "LabelRunningTimeValue": "ความยาว: {0}",
+ "LabelIpAddressValue": "หมายเลข IP: {0}",
+ "Inherit": "ใช้ค่าเริ่มต้น",
+ "HomeVideos": "วิดีโอส่วนตัว",
+ "HeaderNextUp": "รายการถัดไป",
+ "HeaderLiveTV": "ทีวีถ่ายทอดสด",
+ "HeaderFavoriteShows": "รายการที่ชอบ",
+ "HeaderFavoriteEpisodes": "ตอนที่ชอบ",
"HeaderContinueWatching": "ดูต่อ",
"Genres": "ประเภท",
"Folders": "โฟลเดอร์",
@@ -107,6 +107,19 @@
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
- "LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
- "Original": "ต้นฉบับ"
+ "LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ",
+ "Original": "ต้นฉบับ",
+ "NameExtraBehindTheScenes": "เบื้องหลังการถ่ายทำ",
+ "NameExtraClip": "คลิปวิดีโอ",
+ "NameExtraDeletedScene": "ฉากที่ถูกตัดออก",
+ "NameExtraFeaturette": "คลิปสั้นพิเศษ",
+ "NameExtraInterview": "บทสัมภาษณ์",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "ตัวอย่าง",
+ "NameExtraScene": "ฉาก",
+ "NameExtraShort": "ภาพยนตร์สั้น",
+ "NameExtraThemeSong": "เพลงประกอบ",
+ "NameExtraThemeVideo": "วิดีโอธีม",
+ "NameExtraTrailer": "ตัวอย่างภาพยนตร์",
+ "NameExtraUnknown": "เนื้อหาพิเศษ"
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
index 8d133dc074..687947616f 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
/// <summary>
-/// Optimizes Jellyfin's database by issuing a VACUUM command.
+/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands.
/// </summary>
public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
{
@@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
return;
}
- _logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
+ _logger.LogInformation("Vacuuming and analyzing jellyfin.db...");
try
{
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
index afb27ddf9e..092a621bfc 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Emby.Server.Implementations.Library.Validators;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
@@ -29,6 +30,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILogger<PeopleValidationTask> _logger;
+ private readonly ILogger<PeopleValidator> _validatorLogger;
private readonly IItemTypeLookup _itemTypeLookup;
/// <summary>
@@ -39,6 +41,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
+ /// <param name="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> interface.</param>
/// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
public PeopleValidationTask(
ILibraryManager libraryManager,
@@ -46,6 +49,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
IDbContextFactory<JellyfinDbContext> dbContextFactory,
IFileSystem fileSystem,
ILogger<PeopleValidationTask> logger,
+ ILogger<PeopleValidator> validatorLogger,
IItemTypeLookup itemTypeLookup)
{
_libraryManager = libraryManager;
@@ -53,6 +57,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_logger = logger;
+ _validatorLogger = validatorLogger;
_itemTypeLookup = itemTypeLookup;
}
@@ -109,6 +114,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
var dupQuery = context.Peoples
.GroupBy(e => new { e.Name, e.PersonType })
.Where(e => e.Count() > 1)
+ .OrderBy(e => e.Key.Name)
+ .ThenBy(e => e.Key.PersonType)
.Select(e => e.Select(f => f.Id).ToArray());
var total = dupQuery.Count();
@@ -163,7 +170,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
// Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
// cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
- await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
+ await new PeopleValidator(_libraryManager, _validatorLogger)
+ .Run(validateProgress, cancellationToken)
+ .ConfigureAwait(false);
// Phase 3: Refresh images for people missing them (66-100%)
IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index f4aa0ad03a..94215bed79 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session
{
if (!session.SessionControllers.Any(i => i.IsSessionActive))
{
- var key = GetSessionKey(session.Client, session.DeviceId);
+ var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
_activeConnections.TryRemove(key, out _);
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
@@ -369,7 +369,7 @@ namespace Emby.Server.Implementations.Session
if (session is not null)
{
- var key = GetSessionKey(session.Client, session.DeviceId);
+ var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
_activeConnections.TryRemove(key, out _);
@@ -475,8 +475,11 @@ namespace Emby.Server.Implementations.Session
}
}
- private static string GetSessionKey(string appName, string deviceId)
- => appName + deviceId;
+ // The user is part of the key because the client name and the device id are taken from the
+ // request headers and are not bound to the access token. Without it, any authenticated user
+ // could claim another user's client/device pair and take over their session.
+ private static string GetSessionKey(string appName, string deviceId, Guid userId)
+ => appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture);
/// <summary>
/// Gets the connection.
@@ -500,7 +503,7 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(deviceId);
- var key = GetSessionKey(appName, deviceId);
+ var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
if (ReferenceEquals(newSession, sessionInfo))
@@ -1537,11 +1540,52 @@ namespace Emby.Server.Implementations.Session
return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken);
}
- private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
+ private void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(controllingSession);
+
+ var controllingUserId = controllingSession.UserId;
+
+ // Controlling a session is always allowed when:
+ // - the caller has no associated user (an API key, which is a privileged context),
+ // - the target session is public (has no owning user), or
+ // - the caller's user is associated with the target session.
+ // Controlling a session owned by a different user requires the
+ // EnableRemoteControlOfOtherUsers permission.
+ if (controllingUserId.IsEmpty()
+ || session.UserId.IsEmpty()
+ || session.ContainsUser(controllingUserId))
+ {
+ return;
+ }
+
+ var controllingUser = _userManager.GetUserById(controllingUserId);
+ if (controllingUser is null
+ || !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
+ {
+ throw new SecurityException("The current user does not have permission to remote control other users.");
+ }
+ }
+
+ private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId)
+ {
+ var controllingUserId = controllingSession.UserId;
+
+ // Playback reported by a session is also written to the user data of its additional users,
+ // so attaching anyone but the calling user requires administrative privileges.
+ if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId))
+ {
+ return;
+ }
+
+ var controllingUser = _userManager.GetUserById(controllingUserId);
+ if (controllingUser is null
+ || !controllingUser.HasPermission(PermissionKind.IsAdministrator))
+ {
+ throw new SecurityException("The current user does not have permission to attach another user to a session.");
+ }
}
/// <summary>
@@ -1559,16 +1603,24 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Adds the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
+ /// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception>
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
- public void AddAdditionalUser(string sessionId, Guid userId)
+ public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ var controllingSession = GetSession(controllingSessionId);
+ AssertCanControl(session, controllingSession);
+ AssertCanAttachUser(controllingSession, userId);
+ }
+
if (session.UserId.Equals(userId))
{
throw new ArgumentException("The requested user is already the primary user of the session.");
@@ -1576,7 +1628,8 @@ namespace Emby.Server.Implementations.Session
if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId)))
{
- var user = _userManager.GetUserById(userId);
+ var user = _userManager.GetUserById(userId)
+ ?? throw new ArgumentException("The requested user does not exist.");
var newUser = new SessionUserInfo
{
UserId = userId,
@@ -1590,16 +1643,22 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Removes the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
+ /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
- public void RemoveAdditionalUser(string sessionId, Guid userId)
+ public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
if (session.UserId.Equals(userId))
{
throw new ArgumentException("The requested user is already the primary user of the session.");
@@ -1803,14 +1862,21 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Reports the capabilities.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="capabilities">The capabilities.</param>
- public void ReportCapabilities(string sessionId, ClientCapabilities capabilities)
+ /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
+ public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
ReportCapabilities(session, capabilities, true);
}
@@ -1905,13 +1971,18 @@ namespace Emby.Server.Implementations.Session
}
/// <inheritdoc />
- public void ReportNowViewingItem(string sessionId, string itemId)
+ public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId)
{
ArgumentException.ThrowIfNullOrEmpty(itemId);
var item = _libraryManager.GetItemById(new Guid(itemId));
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
session.NowViewingItem = GetItemInfo(item, null);
}
diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs
index 38a0018a70..923bfc67aa 100644
--- a/Emby.Server.Implementations/SyncPlay/Group.cs
+++ b/Emby.Server.Implementations/SyncPlay/Group.cs
@@ -91,6 +91,18 @@ namespace Emby.Server.Implementations.SyncPlay
public long DefaultPing { get; } = 500;
/// <summary>
+ /// Gets the maximum ping, in milliseconds, accepted from a session.
+ /// </summary>
+ /// <remarks>
+ /// Pings are reported by clients and are scaled into the delays used to schedule playback,
+ /// so an unbounded value lets a single session push the whole group's resume point
+ /// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a
+ /// usable measurement for synchronisation.
+ /// </remarks>
+ /// <value>The maximum ping.</value>
+ public long MaxPing { get; } = 10000;
+
+ /// <summary>
/// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
/// </summary>
/// <value>The maximum time offset error.</value>
@@ -438,7 +450,7 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (_participants.TryGetValue(session.Id, out GroupMember value))
{
- value.Ping = ping;
+ value.Ping = Math.Clamp(ping, 0, MaxPing);
}
}
@@ -451,7 +463,9 @@ namespace Emby.Server.Implementations.SyncPlay
max = Math.Max(max, session.Ping);
}
- return max;
+ // A group with no participants has no ping to report. Returning long.MinValue would
+ // overflow the callers that scale this value into ticks, so fall back to the default.
+ return max == long.MinValue ? DefaultPing : max;
}
/// <inheritdoc />
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
index b45d754554..b88ee33358 100644
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
+++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
@@ -181,8 +181,8 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (existingGroup.GroupId.Equals(request.GroupId))
{
- // Restore session.
- UpdateSessionsCounter(session.UserId, 1);
+ // Restore session. The session is already in the group and has already
+ // been counted, so the counter must not be incremented a second time.
group.SessionJoin(session, request, cancellationToken);
return;
}
@@ -332,8 +332,11 @@ namespace Emby.Server.Implementations.SyncPlay
// Group lock required as Group is not thread-safe.
lock (group)
{
- // Make sure that session still belongs to this group.
- if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
+ // Make sure that session still belongs to this group. The lookup can fail
+ // outright when the session left while this request was waiting on the group
+ // lock, which is exactly the case this re-check exists to catch.
+ if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup)
+ || !checkGroup.GroupId.Equals(group.GroupId))
{
// Drop request.
return;
@@ -400,7 +403,7 @@ namespace Emby.Server.Implementations.SyncPlay
// Update sessions counter.
var newSessionsCounter = _activeUsers.AddOrUpdate(
userId,
- 1,
+ toAdd,
(_, sessionsCounter) => sessionsCounter + toAdd);
// Should never happen.
diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs
index f19ca77818..fdbbace1e7 100644
--- a/Jellyfin.Api/Controllers/ArtistsController.cs
+++ b/Jellyfin.Api/Controllers/ArtistsController.cs
@@ -126,6 +126,12 @@ public class ArtistsController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = null;
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
@@ -193,31 +199,7 @@ public class ArtistsController : BaseJellyfinApiController
var result = _libraryManager.GetArtists(query);
- var dtos = result.Items.Select(i =>
- {
- var (baseItem, itemCounts) = i;
- var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
- if (includeItemTypes.Length != 0)
- {
- dto.ChildCount = itemCounts.ItemCount;
- dto.ProgramCount = itemCounts.ProgramCount;
- dto.SeriesCount = itemCounts.SeriesCount;
- dto.EpisodeCount = itemCounts.EpisodeCount;
- dto.MovieCount = itemCounts.MovieCount;
- dto.TrailerCount = itemCounts.TrailerCount;
- dto.AlbumCount = itemCounts.AlbumCount;
- dto.SongCount = itemCounts.SongCount;
- dto.ArtistCount = itemCounts.ArtistCount;
- }
-
- return dto;
- });
-
- return new QueryResult<BaseItemDto>(
- query.StartIndex,
- result.TotalRecordCount,
- dtos.ToArray());
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
@@ -298,6 +280,12 @@ public class ArtistsController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = null;
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
@@ -365,31 +353,7 @@ public class ArtistsController : BaseJellyfinApiController
var result = _libraryManager.GetAlbumArtists(query);
- var dtos = result.Items.Select(i =>
- {
- var (baseItem, itemCounts) = i;
- var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
- if (includeItemTypes.Length != 0)
- {
- dto.ChildCount = itemCounts.ItemCount;
- dto.ProgramCount = itemCounts.ProgramCount;
- dto.SeriesCount = itemCounts.SeriesCount;
- dto.EpisodeCount = itemCounts.EpisodeCount;
- dto.MovieCount = itemCounts.MovieCount;
- dto.TrailerCount = itemCounts.TrailerCount;
- dto.AlbumCount = itemCounts.AlbumCount;
- dto.SongCount = itemCounts.SongCount;
- dto.ArtistCount = itemCounts.ArtistCount;
- }
-
- return dto;
- });
-
- return new QueryResult<BaseItemDto>(
- query.StartIndex,
- result.TotalRecordCount,
- dtos.ToArray());
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs
index 39c3f5abcf..18a8b67be2 100644
--- a/Jellyfin.Api/Controllers/GenresController.cs
+++ b/Jellyfin.Api/Controllers/GenresController.cs
@@ -97,6 +97,12 @@ public class GenresController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
@@ -143,8 +149,7 @@ public class GenresController : BaseJellyfinApiController
result = _libraryManager.GetGenres(query);
}
- var shouldIncludeItemTypes = includeItemTypes.Length != 0;
- return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index e46795554b..65bfe25d21 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -14,7 +14,9 @@ using MediaBrowser.Common.Api;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization;
@@ -33,6 +35,7 @@ public class LibraryStructureController : BaseJellyfinApiController
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly ILibraryMonitor _libraryMonitor;
+ private readonly IDirectoryService _directoryService;
/// <summary>
/// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
@@ -40,14 +43,17 @@ public class LibraryStructureController : BaseJellyfinApiController
/// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
/// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
+ /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param>
public LibraryStructureController(
IServerConfigurationManager serverConfigurationManager,
ILibraryManager libraryManager,
- ILibraryMonitor libraryMonitor)
+ ILibraryMonitor libraryMonitor,
+ IDirectoryService directoryService)
{
_appPaths = serverConfigurationManager.ApplicationPaths;
_libraryManager = libraryManager;
_libraryMonitor = libraryMonitor;
+ _directoryService = directoryService;
}
/// <summary>
@@ -122,12 +128,14 @@ public class LibraryStructureController : BaseJellyfinApiController
/// <param name="newName">The new name.</param>
/// <param name="refreshLibrary">Whether to refresh the library.</param>
/// <response code="204">Folder renamed.</response>
+ /// <response code="400">The new name is not a valid library name.</response>
/// <response code="404">Library doesn't exist.</response>
/// <response code="409">Library already exists.</response>
- /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
+ /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="BadRequestResult"/> if the new name is invalid, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
/// <exception cref="ArgumentNullException">The new name may not be null.</exception>
[HttpPost("Name")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public ActionResult RenameVirtualFolder(
@@ -147,10 +155,15 @@ public class LibraryStructureController : BaseJellyfinApiController
var rootFolderPath = _appPaths.DefaultUserViewsPath;
- var currentPath = Path.Combine(rootFolderPath, name);
- var newPath = Path.Combine(rootFolderPath, newName);
+ // Both names are caller supplied, so they have to be confined to the libraries root.
+ var newPath = FileSystemHelper.GetChildPath(rootFolderPath, newName);
+ if (newPath is null)
+ {
+ return BadRequest("The new name is not a valid library name.");
+ }
- if (!Directory.Exists(currentPath))
+ var currentPath = FileSystemHelper.GetChildPath(rootFolderPath, name);
+ if (currentPath is null || !Directory.Exists(currentPath))
{
return NotFound("The media collection does not exist.");
}
@@ -170,11 +183,11 @@ public class LibraryStructureController : BaseJellyfinApiController
var tempPath = Path.Combine(
rootFolderPath,
Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
- Directory.Move(currentPath, tempPath);
+ _directoryService.Move(currentPath, tempPath);
currentPath = tempPath;
}
- Directory.Move(currentPath, newPath);
+ _directoryService.Move(currentPath, newPath);
}
finally
{
diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs
index 7af44f8bd6..4ebf914895 100644
--- a/Jellyfin.Api/Controllers/MusicGenresController.cs
+++ b/Jellyfin.Api/Controllers/MusicGenresController.cs
@@ -98,6 +98,12 @@ public class MusicGenresController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
@@ -134,8 +140,7 @@ public class MusicGenresController : BaseJellyfinApiController
var result = _libraryManager.GetMusicGenres(query);
- var shouldIncludeItemTypes = includeItemTypes.Length != 0;
- return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index 048a49ffd4..9bfe0f2570 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -521,7 +521,7 @@ public class PlaylistsController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
{
- var callingUserId = userId ?? User.GetUserId();
+ var callingUserId = RequestHelpers.GetUserId(User, userId);
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
if (playlist is null)
{
diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs
index a144961d74..84c2d90fb1 100644
--- a/Jellyfin.Api/Controllers/SessionController.cs
+++ b/Jellyfin.Api/Controllers/SessionController.cs
@@ -306,11 +306,14 @@ public class SessionController : BaseJellyfinApiController
[HttpPost("Sessions/{sessionId}/User/{userId}")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult AddUserToSession(
+ public async Task<ActionResult> AddUserToSession(
[FromRoute, Required] string sessionId,
[FromRoute, Required] Guid userId)
{
- _sessionManager.AddAdditionalUser(sessionId, userId);
+ _sessionManager.AddAdditionalUser(
+ await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
+ sessionId,
+ userId);
return NoContent();
}
@@ -324,11 +327,14 @@ public class SessionController : BaseJellyfinApiController
[HttpDelete("Sessions/{sessionId}/User/{userId}")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult RemoveUserFromSession(
+ public async Task<ActionResult> RemoveUserFromSession(
[FromRoute, Required] string sessionId,
[FromRoute, Required] Guid userId)
{
- _sessionManager.RemoveAdditionalUser(sessionId, userId);
+ _sessionManager.RemoveAdditionalUser(
+ await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
+ sessionId,
+ userId);
return NoContent();
}
@@ -352,12 +358,13 @@ public class SessionController : BaseJellyfinApiController
[FromQuery] bool supportsMediaControl = false,
[FromQuery] bool supportsPersistentIdentifier = true)
{
+ var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(id))
{
- id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+ id = currentSessionId;
}
- _sessionManager.ReportCapabilities(id, new ClientCapabilities
+ _sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities
{
PlayableMediaTypes = playableMediaTypes,
SupportedCommands = supportedCommands,
@@ -381,12 +388,13 @@ public class SessionController : BaseJellyfinApiController
[FromQuery] string? id,
[FromBody, Required] ClientCapabilitiesDto capabilities)
{
+ var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(id))
{
- id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+ id = currentSessionId;
}
- _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
+ _sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities());
return NoContent();
}
@@ -405,9 +413,9 @@ public class SessionController : BaseJellyfinApiController
[FromQuery] string? sessionId,
[FromQuery, Required] string? itemId)
{
- string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+ var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
- _sessionManager.ReportNowViewingItem(session, itemId);
+ _sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId);
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs
index a8feb206a4..5bac850859 100644
--- a/Jellyfin.Api/Controllers/StudiosController.cs
+++ b/Jellyfin.Api/Controllers/StudiosController.cs
@@ -92,6 +92,12 @@ public class StudiosController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
@@ -126,8 +132,7 @@ public class StudiosController : BaseJellyfinApiController
}
var result = _libraryManager.GetStudios(query);
- var shouldIncludeItemTypes = includeItemTypes.Length != 0;
- return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index e5df873f5b..c4851091c1 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -557,7 +557,7 @@ public class SubtitleController : BaseJellyfinApiController
if (!string.IsNullOrEmpty(fallbackFontPath))
{
var fontFile = _fileSystem.GetFiles(fallbackFontPath)
- .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+ .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
var fileSize = fontFile?.Length;
if (fontFile is not null && fileSize is not null && fileSize > 0)
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index b09b279699..4995d37f4b 100644
--- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -458,6 +458,7 @@ public class DynamicHlsHelper
{
case VideoRangeType.HLG:
case VideoRangeType.DOVIWithHLG:
+ case VideoRangeType.DOVIInvalid when string.Equals(state.VideoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase):
builder.Append(",VIDEO-RANGE=HLG");
break;
default:
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index d14c3a9343..4c5ed16015 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -156,7 +156,6 @@ public static class RequestHelpers
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result,
DtoOptions dtoOptions,
IDtoService dtoService,
- bool includeItemTypes,
User? user)
{
var dtos = result.Items.Select(i =>
@@ -164,7 +163,7 @@ public static class RequestHelpers
var (baseItem, counts) = i;
var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
- if (includeItemTypes)
+ if (counts is not null)
{
dto.ChildCount = counts.ItemCount;
dto.ProgramCount = counts.ProgramCount;
@@ -175,6 +174,7 @@ public static class RequestHelpers
dto.AlbumCount = counts.AlbumCount;
dto.SongCount = counts.SongCount;
dto.ArtistCount = counts.ArtistCount;
+ dto.MusicVideoCount = counts.MusicVideoCount;
}
return dto;
diff --git a/Jellyfin.Data/Enums/VideoRangeType.cs b/Jellyfin.Data/Enums/VideoRangeType.cs
index ce232d73c3..e7cc340d21 100644
--- a/Jellyfin.Data/Enums/VideoRangeType.cs
+++ b/Jellyfin.Data/Enums/VideoRangeType.cs
@@ -61,8 +61,9 @@ public enum VideoRangeType
DOVIWithELHDR10Plus,
/// <summary>
- /// Dolby Vision with invalid configuration. e.g. Profile 8 compat id 6.
- /// When using this range, the server would assume the video is still HDR10 after removing the Dolby Vision metadata.
+ /// Dolby Vision with invalid configuration, e.g. Profile 8 compat id 6 or inconsistent base-layer color metadata.
+ /// The base layer is classified as HDR only when its transfer characteristics signal PQ or HLG.
+ /// Otherwise, it is classified as SDR.
/// </summary>
DOVIInvalid,
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 5a41619390..70e4ca3b1d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -121,20 +121,22 @@ public sealed partial class BaseItemRepository
{
using var context = _dbProvider.CreateDbContext();
- var query = context.ItemValuesMap
- .AsNoTracking()
- .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type));
+ var maps = context.ItemValuesMap.AsNoTracking();
if (withItemTypes.Count > 0)
{
- query = query.Where(e => withItemTypes.Contains(e.Item.Type));
+ maps = maps.Where(e => withItemTypes.Contains(e.Item.Type));
}
if (excludeItemTypes.Count > 0)
{
- query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type));
+ maps = maps.Where(e => !excludeItemTypes.Contains(e.Item.Type));
}
- return query.Select(e => e.ItemValue)
+ return context.ItemValues
+ .AsNoTracking()
+ .WhereOneOrMany(itemValueTypes, e => e.Type)
+ .Where(e => maps.Any(m => m.ItemValueId == e.ItemValueId))
+ .Select(e => new { e.CleanValue, e.Value })
.GroupBy(e => e.CleanValue)
.Select(g => g.Min(v => v.Value)!)
.ToArray();
@@ -246,14 +248,20 @@ public sealed partial class BaseItemRepository
}
result.StartIndex = filter.StartIndex ?? 0;
- if (filter.IncludeItemTypes.Length > 0)
+ var page = query.AsEnumerable().Where(e => e is not null).ToList();
+
+ if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts))
{
- var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes);
+ var pageCleanNames = page
+ .Where(e => !string.IsNullOrEmpty(e.CleanName))
+ .Select(e => e.CleanName!)
+ .Distinct()
+ .ToList();
+
+ var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames);
result.Items =
[
- .. query
- .AsEnumerable()
- .Where(e => e is not null)
+ .. page
.Select(e =>
{
var item = DeserializeBaseItem(e, filter.SkipDeserialization);
@@ -268,9 +276,7 @@ public sealed partial class BaseItemRepository
{
result.Items =
[
- .. query
- .AsEnumerable()
- .Where(e => e != null)
+ .. page
.Select(e => DeserializeBaseItem(e, filter.SkipDeserialization))
.Where(item => item != null)
.Select(item => (item!, (ItemCounts?)null))
@@ -281,14 +287,22 @@ public sealed partial class BaseItemRepository
}
private Dictionary<string, ItemCounts> BuildItemCountsByCleanName(
- Database.Implementations.JellyfinDbContext context,
+ JellyfinDbContext context,
InternalItemsQuery filter,
- IReadOnlyList<ItemValueType> itemValueTypes)
+ IReadOnlyList<ItemValueType> itemValueTypes,
+ IReadOnlyList<string> cleanNames)
{
- var typeSubQuery = new InternalItemsQuery(filter.User)
+ var countsByCleanName = new Dictionary<string, ItemCounts>();
+ if (cleanNames.Count == 0)
+ {
+ return countsByCleanName;
+ }
+
+ // The counts describe everything the value is attached to, not only the types the list was
+ // filtered down to.
+ var scopeQuery = new InternalItemsQuery(filter.User)
{
ExcludeItemTypes = filter.ExcludeItemTypes,
- IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ExcludeItemIds = filter.ExcludeItemIds,
@@ -298,33 +312,51 @@ public sealed partial class BaseItemRepository
IsPlayed = filter.IsPlayed
};
- var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
- .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
+ var scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery);
+ var valueLinks = context.ItemValuesMap
+ .AsNoTracking()
+ .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
+ .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
+ var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo];
+ var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram];
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
- var itemIds = itemCountQuery.Select(e => e.Id);
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems.
- var rawCounts = context.ItemValuesMap
- .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
- .Where(ivm => itemIds.Contains(ivm.ItemId))
+ var rawCounts = valueLinks
.Join(
- context.BaseItems,
+ scopedItems,
ivm => ivm.ItemId,
e => e.Id,
- (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
- .GroupBy(x => new { x.CleanName, x.Type })
- .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
- .AsEnumerable();
+ (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId })
+ .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId })
+ .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() })
+ .ToList();
+
+ // Only studios and genres pass down from a series to its episodes; an artist credit does not.
+ var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre);
+ var episodeCounts = inheritsToEpisodes
+ ? BuildEpisodeCountsByCleanName(
+ scopedItems,
+ valueLinks,
+ rawCounts
+ .Where(x => x.Type == episodeTypeName)
+ .Select(x => (x.CleanName, x.SeriesId, x.Count))
+ .ToList(),
+ seriesTypeName,
+ episodeTypeName)
+ : rawCounts
+ .Where(x => x.Type == episodeTypeName)
+ .GroupBy(x => x.CleanName)
+ .ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
- var countsByCleanName = new Dictionary<string, ItemCounts>();
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
{
var counts = new ItemCounts();
@@ -334,10 +366,6 @@ public sealed partial class BaseItemRepository
{
counts.SeriesCount += row.Count;
}
- else if (row.Type == episodeTypeName)
- {
- counts.EpisodeCount += row.Count;
- }
else if (row.Type == movieTypeName)
{
counts.MovieCount += row.Count;
@@ -350,6 +378,14 @@ public sealed partial class BaseItemRepository
{
counts.ArtistCount += row.Count;
}
+ else if (row.Type == musicVideoTypeName)
+ {
+ counts.MusicVideoCount += row.Count;
+ }
+ else if (row.Type == programTypeName)
+ {
+ counts.ProgramCount += row.Count;
+ }
else if (row.Type == audioTypeName)
{
counts.SongCount += row.Count;
@@ -360,9 +396,72 @@ public sealed partial class BaseItemRepository
}
}
+ // Episodes are counted separately: the value is usually only written on the series.
+ counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key);
+ counts.ItemCount = counts.TotalItemCount();
countsByCleanName[group.Key] = counts;
}
+ // A value carried by nothing but the episodes below a tagged series has no row of its own.
+ foreach (var (cleanName, episodeCount) in episodeCounts)
+ {
+ if (!countsByCleanName.ContainsKey(cleanName))
+ {
+ countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount };
+ }
+ }
+
return countsByCleanName;
}
+
+ private static Dictionary<string, int> BuildEpisodeCountsByCleanName(
+ IQueryable<BaseItemEntity> scopedItems,
+ IQueryable<ItemValueMap> valueLinks,
+ IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes,
+ string seriesTypeName,
+ string episodeTypeName)
+ {
+ // Resolved in steps rather than as one union: each of these drives off an index, while the
+ // single-statement form leaves SQLite free to scan every episode in the library instead.
+ var taggedSeries = valueLinks
+ .Join(
+ scopedItems.Where(e => e.Type == seriesTypeName),
+ ivm => ivm.ItemId,
+ e => e.Id,
+ (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id })
+ .ToList();
+
+ var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray();
+ var episodesPerSeries = seriesIds.Length == 0
+ ? []
+ : scopedItems
+ .Where(e => e.Type == episodeTypeName && e.SeriesId != null)
+ .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value)
+ .GroupBy(e => e.SeriesId!.Value)
+ .Select(g => new { SeriesId = g.Key, Count = g.Count() })
+ .ToDictionary(x => x.SeriesId, x => x.Count);
+
+ var episodeCounts = new Dictionary<string, int>();
+ var seriesByCleanName = new Dictionary<string, HashSet<Guid>>();
+ foreach (var group in taggedSeries.GroupBy(x => x.CleanName))
+ {
+ var series = group.Select(x => x.SeriesId).ToHashSet();
+ seriesByCleanName[group.Key] = series;
+ episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id));
+ }
+
+ foreach (var (cleanName, seriesId, count) in taggedEpisodes)
+ {
+ if (seriesId is not null
+ && seriesByCleanName.TryGetValue(cleanName, out var series)
+ && series.Contains(seriesId.Value))
+ {
+ continue;
+ }
+
+ episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count;
+ }
+
+ return episodeCounts;
+ }
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index c9e08b1b5d..1ed10cce2b 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -626,18 +626,26 @@ public sealed partial class BaseItemRepository
.ToArray();
var tags = context.ItemValuesMap
- .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags)
- .Where(ivm => matchingItemIds.Contains(ivm.ItemId))
- .Select(ivm => ivm.ItemValue)
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Tags)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(t => t)
.ToArray();
var genres = context.ItemValuesMap
- .Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre)
- .Where(ivm => matchingItemIds.Contains(ivm.ItemId))
- .Select(ivm => ivm.ItemValue)
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Genre)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(g => g)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 1e30f0164e..d635b38df5 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -1091,6 +1091,12 @@ public sealed partial class BaseItemRepository
baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter));
}
+ if (filter.DescendantOfId.HasValue)
+ {
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value);
+ baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id));
+ }
+
if (filter.LinkedChildAncestorIds.Length > 0)
{
// Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids.
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index c42b5f9581..704dc31fd0 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -249,11 +249,51 @@ public class ItemCountService : IItemCountService
}
}
+ if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
+ && relatedItemKinds.Contains(BaseItemKind.Episode)
+ && relatedItemKinds.Contains(BaseItemKind.Series))
+ {
+ var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
+ totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
+ result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
+ }
+
result.ItemCount = totalCount;
return result;
}
+ private int CountEpisodesOfTaggedSeries(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> taggedItems,
+ InternalItemsQuery accessFilter,
+ out int unrelatedEpisodeCount)
+ {
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
+
+ var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id);
+ unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName
+ && (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value)));
+
+ // Materialised so the episode count drives off IX_BaseItems_SeriesId.
+ var seriesIds = taggedItems
+ .Where(e => e.Type == seriesTypeName)
+ .Select(e => e.Id)
+ .ToArray();
+
+ if (seriesIds.Length == 0)
+ {
+ return 0;
+ }
+
+ var episodes = context.BaseItems.AsNoTracking()
+ .Where(e => e.Type == episodeTypeName && e.SeriesId != null)
+ .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value);
+
+ return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count();
+ }
+
private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
=> context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
@@ -319,7 +359,7 @@ public class ItemCountService : IItemCountService
}
/// <inheritdoc/>
- public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
+ public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
{
ArgumentNullException.ThrowIfNull(parentIds);
@@ -332,20 +372,32 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
+ var includeVirtual = user is null || user.DisplayMissingEpisodes;
+
var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue)
+ .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
+ // An episode is a child of its season even when it is not stored under one: with a flat
+ // structure ParentId points at the series, so counting by ParentId alone leaves the season
+ // empty and counts its episodes towards the series instead.
+ var seasonCounts = dbContext.BaseItems
+ .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
+ .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
+ .GroupBy(b => b.SeasonId!.Value)
+ .Select(g => new { SeasonId = g.Key, Count = g.Count() })
+ .ToDictionary(x => x.SeasonId, x => x.Count);
+
var linkedCounts = dbContext.LinkedChildren
.WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
- var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual);
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
@@ -356,7 +408,8 @@ public class ItemCountService : IItemCountService
continue;
}
- var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
+ var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0)
+ + seasonCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount;
@@ -365,7 +418,7 @@ public class ItemCountService : IItemCountService
return result;
}
- private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
+ private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual)
{
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
.Where(group => group.Value.Count > 1)
@@ -380,10 +433,16 @@ public class ItemCountService : IItemCountService
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
var children = dbContext.BaseItems
.AsNoTracking()
- .Where(b => b.ParentId.HasValue)
+ .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
.ToArray()
+ .Concat(dbContext.BaseItems
+ .AsNoTracking()
+ .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
+ .WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
+ .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
+ .ToArray())
.GroupBy(b => b.ParentId)
.ToDictionary(
g => g.Key,
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index efff3457a3..c8672e189b 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -176,14 +176,6 @@ public class ItemPersistenceService : IItemPersistenceService
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
- if (!await context.BaseItems
- .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
- .ConfigureAwait(false))
- {
- _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
- return;
- }
-
await context.BaseItemImageInfos
.Where(e => e.ItemId == item.Id)
.ExecuteDeleteAsync(cancellationToken)
@@ -193,7 +185,26 @@ public class ItemPersistenceService : IItemPersistenceService
.AddRangeAsync(images, cancellationToken)
.ConfigureAwait(false);
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (DbUpdateException)
+ {
+ // Checking that the item exists before writing leaves a gap a scan can delete it
+ // through, turning the insert into a foreign key violation that fails the whole
+ // refresh instead of the no-op intended here. Let the insert be the check: it is the
+ // only point at which the answer cannot go stale. Nothing is orphaned by the delete
+ // above, because deleting the item cascades to its images anyway.
+ if (await context.BaseItems
+ .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ throw;
+ }
+
+ _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id);
+ }
}
}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index beafc3916f..a5b6bc4604 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -183,7 +183,13 @@ internal class JellyfinMigrationService
}
}
- public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
+ /// <summary>
+ /// Runs all pending migrations of the requested stage.
+ /// </summary>
+ /// <param name="stage">The stage to migrate.</param>
+ /// <param name="serviceProvider">The service provider handed to the migrations.</param>
+ /// <returns>A value indicating whether at least one migration has been applied.</returns>
+ public async Task<bool> MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider serviceProvider)
{
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? [];
@@ -297,6 +303,8 @@ internal class JellyfinMigrationService
completedMigrations++;
}
+
+ return completedMigrations > 0;
}
}
@@ -445,10 +453,10 @@ internal class JellyfinMigrationService
private class InternalCodeMigration : IInternalMigration
{
private readonly CodeMigration _codeMigration;
- private readonly IServiceProvider? _serviceProvider;
+ private readonly IServiceProvider _serviceProvider;
private JellyfinDbContext _dbContext;
- public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext)
+ public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider serviceProvider, JellyfinDbContext dbContext)
{
_codeMigration = codeMigration;
_serviceProvider = serviceProvider;
diff --git a/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs
index 8a0a1741f1..291de23b2e 100644
--- a/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs
+++ b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs
@@ -29,7 +29,7 @@ internal class MigrateLibraryUserData : IAsyncMigrationRoutine
private readonly IDbContextFactory<JellyfinDbContext> _provider;
public MigrateLibraryUserData(
- IStartupLogger<MigrateLibraryDb> startupLogger,
+ IStartupLogger<MigrateLibraryUserData> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths)
{
diff --git a/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs
index 502763ac09..c8ee44a670 100644
--- a/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs
+++ b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs
@@ -24,7 +24,7 @@ internal class ReseedFolderFlag : IAsyncMigrationRoutine
private readonly IDbContextFactory<JellyfinDbContext> _provider;
public ReseedFolderFlag(
- IStartupLogger<MigrateLibraryDb> startupLogger,
+ IStartupLogger<ReseedFolderFlag> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths)
{
diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
index 4a6e74c229..8f239007d8 100644
--- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
@@ -22,6 +22,11 @@ namespace Jellyfin.Server.Migrations.Routines;
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
{
+ private const int ParseProgressLogStep = 25_000;
+ private const int FileCheckProgressLogStep = 10_000;
+ private const int ResolveProgressLogStep = 10_000;
+ private const int DeleteProgressLogStep = 25;
+
private readonly ILogger<MigrateLinkedChildren> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly ILibraryManager _libraryManager;
@@ -85,7 +90,6 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var droppedChildren = 0;
var linkedChildrenToAdd = new List<LinkedChildEntity>();
var processedCount = 0;
- const int progressLogStep = 1000;
var totalItems = itemsWithData.Count;
foreach (var item in itemsWithData)
@@ -95,7 +99,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
continue;
}
- if (processedCount > 0 && processedCount % progressLogStep == 0)
+ if (processedCount > 0 && processedCount % ParseProgressLogStep == 0)
{
_logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems);
}
@@ -311,11 +315,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
- var itemsToDelete = wrongTypeChildIds
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- var deleted = DeleteItems(itemsToDelete!);
+ var deleted = ResolveAndDeleteItems(wrongTypeChildIds, "wrong-type alternate version items");
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted);
}
@@ -342,11 +342,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count);
- var itemsToDelete = orphanedVersionIds
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- var deleted = DeleteItems(itemsToDelete!);
+ var deleted = ResolveAndDeleteItems(orphanedVersionIds, "orphaned alternate version BaseItems");
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
}
@@ -371,11 +367,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
- var itemsToDelete = orphanedIds
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- var deleted = DeleteItems(itemsToDelete!);
+ var deleted = ResolveAndDeleteItems(orphanedIds, "items from deleted libraries");
_logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
}
@@ -427,8 +419,23 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var skippedUnrootedItems = 0;
var staleIds = new List<Guid>();
+ var checkedCount = 0;
+ _logger.LogInformation("Checking {Total} items for missing files.", itemsWithPaths.Count);
+
foreach (var item in itemsWithPaths)
{
+ // A miss on offline storage can block for the mount timeout, so report while scanning.
+ if (checkedCount > 0 && checkedCount % FileCheckProgressLogStep == 0)
+ {
+ _logger.LogInformation(
+ "Checking for missing files: {Checked}/{Total} items, {Stale} stale so far.",
+ checkedCount,
+ itemsWithPaths.Count,
+ staleIds.Count);
+ }
+
+ checkedCount++;
+
// Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
var path = _appHost.ExpandVirtualPath(item.Path!);
@@ -482,16 +489,47 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
- var itemsToDelete = staleIds
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- var deleted = DeleteItems(itemsToDelete!);
+ var deleted = ResolveAndDeleteItems(staleIds, "items with missing files");
_logger.LogInformation("Removed {Count} stale items.", deleted);
}
- private int DeleteItems(IReadOnlyCollection<BaseItem> items)
+ private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description)
+ {
+ if (ids.Count == 0)
+ {
+ return 0;
+ }
+
+ return DeleteItems(ResolveItems(ids, description), description);
+ }
+
+ private List<BaseItem> ResolveItems(IReadOnlyCollection<Guid> ids, string description)
+ {
+ // Each lookup is a separate repository read; cached ones are fast, so this only reports
+ // once a set is large enough for the reads to add up to a noticeable stretch.
+ var items = new List<BaseItem>(ids.Count);
+ var processed = 0;
+ foreach (var id in ids)
+ {
+ if (processed > 0 && processed % ResolveProgressLogStep == 0)
+ {
+ _logger.LogInformation("Loading {Description}: {Processed}/{Total} items", description, processed, ids.Count);
+ }
+
+ processed++;
+
+ var item = _libraryManager.GetItemById(id);
+ if (item is not null)
+ {
+ items.Add(item);
+ }
+ }
+
+ return items;
+ }
+
+ private int DeleteItems(IReadOnlyCollection<BaseItem> items, string description)
{
if (items.Count == 0)
{
@@ -500,8 +538,16 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
var deleted = 0;
+ var processed = 0;
foreach (var item in items)
{
+ if (processed > 0 && processed % DeleteProgressLogStep == 0)
+ {
+ _logger.LogInformation("Removing {Description}: {Processed}/{Total} items", description, processed, items.Count);
+ }
+
+ processed++;
+
try
{
_libraryManager.DeleteItem(item, options);
diff --git a/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs
new file mode 100644
index 0000000000..e665725ced
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.Migrations.Stages;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Enables the local similarity providers on libraries that predate the similar items settings.
+/// </summary>
+[JellyfinMigration("2026-08-31T10:00:00", nameof(EnableLocalSimilarityProviders), Stage = JellyfinMigrationStageTypes.AppInitialisation)]
+internal class EnableLocalSimilarityProviders : IAsyncMigrationRoutine
+{
+ private readonly ILibraryManager _libraryManager;
+ private readonly IProviderManager _providerManager;
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="EnableLocalSimilarityProviders"/> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="providerManager">The provider manager.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI integration.</param>
+ /// <param name="logger">The logger.</param>
+ public EnableLocalSimilarityProviders(
+ ILibraryManager libraryManager,
+ IProviderManager providerManager,
+ IStartupLogger<EnableLocalSimilarityProviders> startupLogger,
+ ILogger<EnableLocalSimilarityProviders> logger)
+ {
+ _libraryManager = libraryManager;
+ _providerManager = providerManager;
+ _logger = startupLogger.With(logger);
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ // Libraries created before similar items became configurable have an empty provider list,
+ // which the library editor renders as "everything unchecked" instead of falling back to the
+ // defaults it uses for new libraries. Seed the local providers so they stay enabled.
+ var localProvidersByType = GetLocalProvidersByItemType();
+ if (localProvidersByType.Count == 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ foreach (var virtualFolder in _libraryManager.GetVirtualFolders(false))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ EnableLocalProviders(virtualFolder, localProvidersByType);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void EnableLocalProviders(VirtualFolderInfo virtualFolder, Dictionary<string, string[]> localProvidersByType)
+ {
+ var options = virtualFolder.LibraryOptions;
+ if (options?.TypeOptions is null || options.TypeOptions.Length == 0)
+ {
+ return;
+ }
+
+ // Some virtual folders don't have a proper item id.
+ if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
+ {
+ return;
+ }
+
+ var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
+ if (collectionFolder is null)
+ {
+ _logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
+ return;
+ }
+
+ var changed = false;
+ foreach (var typeOptions in options.TypeOptions)
+ {
+ changed |= EnableLocalProviders(typeOptions, localProvidersByType, virtualFolder.Name);
+ }
+
+ if (changed)
+ {
+ collectionFolder.UpdateLibraryOptions(options);
+ }
+ }
+
+ private bool EnableLocalProviders(TypeOptions typeOptions, Dictionary<string, string[]> localProvidersByType, string libraryName)
+ {
+ if (typeOptions.Type is null || !localProvidersByType.TryGetValue(typeOptions.Type, out var localProviders))
+ {
+ return false;
+ }
+
+ var enabled = typeOptions.SimilarItemProviders ?? [];
+ var missing = localProviders.Where(name => !enabled.Contains(name, StringComparer.OrdinalIgnoreCase)).ToArray();
+ if (missing.Length == 0)
+ {
+ return false;
+ }
+
+ // Local providers rank ahead of remote ones, and the enabled list doubles as the
+ // priority order when no explicit order was saved.
+ typeOptions.SimilarItemProviders = [.. missing, .. enabled];
+ if (typeOptions.SimilarItemProviderOrder is { Length: > 0 } order)
+ {
+ typeOptions.SimilarItemProviderOrder = [.. missing, .. order];
+ }
+
+ _logger.LogInformation("Enabled local similarity providers {Providers} for '{ItemType}' in library '{LibraryName}'.", missing, typeOptions.Type, libraryName);
+ return true;
+ }
+
+ private Dictionary<string, string[]> GetLocalProvidersByItemType()
+ {
+ var result = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var summary in _providerManager.GetAllMetadataPlugins())
+ {
+ var names = summary.Plugins
+ .Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider)
+ .Select(p => p.Name)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (names.Length > 0)
+ {
+ result[summary.ItemType] = names;
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
index 971b47608f..71706811b8 100644
--- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
+++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
@@ -4,8 +4,6 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Server.ServerSetupApp;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.DependencyInjection.Extensions;
-using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Stages;
@@ -22,66 +20,45 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!;
}
- private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
+ public async Task Perform(IServiceProvider serviceProvider, IStartupLogger logger, CancellationToken cancellationToken)
{
- var childServiceCollection = new ServiceCollection()
- .AddSingleton(serviceProvider)
- .AddSingleton(logger)
- .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>))
- .AddSingleton<StartupLogTopic>(logger.Topic!);
+ if (!IsMigrationRoutine(MigrationType))
+ {
+ throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type");
+ }
- foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>())
+ // The routine runs against a scope of the applications own container. Copying the application service
+ // descriptors into a child container instead would make that child container the owner of every singleton it
+ // forwards, so disposing it after the migration would also dispose the applications own instance of services
+ // like the ProviderManager and leave the server broken until the next restart.
+ var scope = serviceProvider.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
{
- if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition)
+ // Nests everything the routine logs through an injected IStartupLogger under the migrations own topic.
+ using (StartupLogger.BeginAmbientTopic(logger.Topic))
{
- childServiceCollection.AddSingleton(service.ServiceType, _ => serviceProvider.GetService(service.ServiceType)!);
- continue;
+ await RunAsync(ActivatorUtilities.CreateInstance(scope.ServiceProvider, MigrationType), cancellationToken).ConfigureAwait(false);
}
-
- childServiceCollection.Add(service);
}
-
- return childServiceCollection;
}
- public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, CancellationToken cancellationToken)
- {
+ // The obsolete IMigrationRoutine is still implemented by every routine that predates the async interface, so
+ // the members that have to touch it are grouped here behind a single suppression.
#pragma warning disable CS0618 // Type or member is obsolete
- if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType))
- {
- if (serviceProvider is null)
- {
- ((IMigrationRoutine)Activator.CreateInstance(MigrationType)!).Perform();
- }
- else
- {
- using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
- ((IMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).Perform();
-#pragma warning restore CS0618 // Type or member is obsolete
- }
- }
- else if (typeof(IAsyncMigrationRoutine).IsAssignableFrom(MigrationType))
- {
- if (serviceProvider is null)
- {
- await ((IAsyncMigrationRoutine)Activator.CreateInstance(MigrationType)!).PerformAsync(cancellationToken).ConfigureAwait(false);
- }
- else
- {
- using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
- await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false);
- }
- }
- else
- {
- throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type");
- }
+ private static bool IsMigrationRoutine(Type migrationType)
+ {
+ return typeof(IMigrationRoutine).IsAssignableFrom(migrationType) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(migrationType);
}
- private class NestedStartupLogger<TCategory> : StartupLogger<TCategory>
+ private static async Task RunAsync(object routine, CancellationToken cancellationToken)
{
- public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic)
+ if (routine is IMigrationRoutine migrationRoutine)
{
+ migrationRoutine.Perform();
+ return;
}
+
+ await ((IAsyncMigrationRoutine)routine).PerformAsync(cancellationToken).ConfigureAwait(false);
}
+#pragma warning restore CS0618 // Type or member is obsolete
}
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index 12f92efb35..35eaff6532 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -61,6 +61,7 @@ namespace Jellyfin.Server
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
private static IStartupLogger<JellyfinMigrationService>? _migrationLogger;
+ private static bool _optimizeDatabaseAfterMigration;
private static string? _restoreFromBackup;
/// <summary>
@@ -180,9 +181,7 @@ namespace Jellyfin.Server
})
.ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
.UseSerilog()
- .ConfigureServices(e => e
- .RegisterStartupLogger()
- .AddSingleton<IServiceCollection>(e))
+ .ConfigureServices(e => e.RegisterStartupLogger())
.Build();
/*
@@ -209,14 +208,15 @@ namespace Jellyfin.Server
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
// "Preparing migrations" carries through the DB read; per-migration progress is reported
// as "Running migration X of Y" from inside the step once the pending set is known.
- await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
+ _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
SetupServer.ReportActivity(StartupActivity.InitializingServices);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
_appHost = appHost;
- await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
+ _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
+ await OptimizeDatabaseAfterMigrationAsync(appHost.ServiceProvider).ConfigureAwait(false);
try
{
configurationCompleted = true;
@@ -271,12 +271,11 @@ namespace Jellyfin.Server
// Don't throw additional exception if startup failed.
if (appHost.ServiceProvider is not null)
{
- _logger.LogInformation("Running query planner optimizations in the database... This might take a while");
+ _logger.LogInformation("Optimizing the database... This might take a while");
+ // Deliberately untimed: a truncated optimization leaves the statistics incomplete.
var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
- using var shutdownSource = new CancellationTokenSource();
- shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds);
- await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false);
+ await databaseProvider.RunShutdownTask(CancellationToken.None).ConfigureAwait(false);
}
_appHost = null;
@@ -307,14 +306,13 @@ namespace Jellyfin.Server
.AddSingleton<ServerApplicationPaths>(appPaths)
.RegisterStartupLogger();
- migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
PrepareDatabaseProvider(startupService);
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService);
await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false);
- await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
+ _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
}
/// <summary>
@@ -329,7 +327,32 @@ namespace Jellyfin.Server
public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage)
{
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider, _migrationLogger!);
- await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
+ _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
+ }
+
+ private static async Task OptimizeDatabaseAfterMigrationAsync(IServiceProvider serviceProvider)
+ {
+ if (!_optimizeDatabaseAfterMigration)
+ {
+ return;
+ }
+
+ // Reset first: a restart runs no migrations and must not optimize again.
+ _optimizeDatabaseAfterMigration = false;
+ SetupServer.ReportActivity(StartupActivity.OptimizingDatabase);
+ _logger.LogInformation("Migrations have been applied, optimizing the database... This might take a while");
+
+ try
+ {
+ // Deliberately untimed: incomplete statistics are worse than a slow start.
+ var databaseProvider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
+ await databaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // A missed optimization only costs performance, so never fail startup over this.
+ _logger.LogError(ex, "Error while optimizing the database after migration");
+ }
}
/// <summary>
diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs
index 888cc617d4..abfc5bc8ba 100644
--- a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs
+++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs
@@ -27,6 +27,9 @@ public static class StartupActivity
/// <summary>Bringing up core services and plugins.</summary>
public const string InitializingServices = "Initializing services";
+ /// <summary>Refreshing the database statistics after migrations have run.</summary>
+ public const string OptimizingDatabase = "Optimizing database";
+
/// <summary>Running the final startup tasks.</summary>
public const string FinishingStartup = "Finishing startup";
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
index 0121854ce3..b72b0c0eab 100644
--- a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
+++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
@@ -1,5 +1,6 @@
using System;
using System.Globalization;
+using System.Threading;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -8,6 +9,8 @@ namespace Jellyfin.Server.ServerSetupApp;
/// <inheritdoc/>
public class StartupLogger : IStartupLogger
{
+ private static readonly AsyncLocal<StartupLogTopic?> _ambientTopic = new();
+
private readonly StartupLogTopic? _topic;
/// <summary>
@@ -17,6 +20,7 @@ public class StartupLogger : IStartupLogger
public StartupLogger(ILogger logger)
{
BaseLogger = logger;
+ _topic = _ambientTopic.Value;
}
/// <summary>
@@ -39,6 +43,18 @@ public class StartupLogger : IStartupLogger
/// </summary>
protected ILogger BaseLogger { get; set; }
+ /// <summary>
+ /// Makes <paramref name="topic"/> the topic that loggers created on this execution context attach to.
+ /// </summary>
+ /// <param name="topic">The topic to nest newly created loggers under.</param>
+ /// <returns>A scope that restores the previously ambient topic when disposed.</returns>
+ internal static IDisposable BeginAmbientTopic(StartupLogTopic? topic)
+ {
+ var scope = new AmbientTopicScope(_ambientTopic.Value);
+ _ambientTopic.Value = topic;
+ return scope;
+ }
+
/// <inheritdoc/>
public IStartupLogger BeginGroup(FormattableString logEntry)
{
@@ -121,4 +137,19 @@ public class StartupLogger : IStartupLogger
Topic.Children.Add(startupEntry);
}
}
+
+ private sealed class AmbientTopicScope : IDisposable
+ {
+ private readonly StartupLogTopic? _previous;
+
+ public AmbientTopicScope(StartupLogTopic? previous)
+ {
+ _previous = previous;
+ }
+
+ public void Dispose()
+ {
+ _ambientTopic.Value = _previous;
+ }
+ }
}
diff --git a/Jellyfin.sln b/Jellyfin.sln
index b0d5a5eb47..b666e4ae16 100644
--- a/Jellyfin.sln
+++ b/Jellyfin.sln
@@ -99,6 +99,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implement
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -265,6 +267,10 @@ Global
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -297,6 +303,7 @@ Global
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
+ {E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index e85f86b72f..eb2a3676ac 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -103,6 +103,7 @@ namespace MediaBrowser.Controller.Entities
|| SubtitleLanguages.Count > 0
|| LinkedChildAncestorIds.Length > 0
|| AncestorIds.Length > 0
+ || DescendantOfId.HasValue
|| IsFavorite.HasValue
|| IsFavoriteOrLiked.HasValue
|| IsLiked.HasValue
@@ -368,6 +369,13 @@ namespace MediaBrowser.Controller.Entities
/// </summary>
public Guid[] LinkedChildAncestorIds { get; set; }
+ /// <summary>
+ /// Gets or sets the id of a folder whose descendants the items must be part of.
+ /// Unlike <see cref="AncestorIds"/> this also follows the linked children of BoxSets and
+ /// Playlists, so it reaches the items below a linked folder (a Series' episodes, for example).
+ /// </summary>
+ public Guid? DescendantOfId { get; set; }
+
public Guid[] TopParentIds { get; set; }
public CollectionType?[] PresetViews { get; set; }
@@ -424,12 +432,18 @@ namespace MediaBrowser.Controller.Entities
public string? HasNoSubtitleTrackWithLanguage { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether to return only items nothing names any more.
+ /// </summary>
public bool? IsDeadArtist { get; set; }
public bool? IsDeadStudio { get; set; }
public bool? IsDeadGenre { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether to return only items nothing names any more.
+ /// </summary>
public bool? IsDeadPerson { get; set; }
/// <summary>
diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs
index 44b7fadf5e..b2d2273cbe 100644
--- a/MediaBrowser.Controller/IO/FileSystemHelper.cs
+++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs
@@ -166,4 +166,40 @@ public static class FileSystemHelper
return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget);
}
+
+ /// <summary>
+ /// Combines a caller supplied name with a parent directory, making sure the name cannot escape that directory.
+ /// </summary>
+ /// <param name="parentPath">The directory the name has to resolve inside of.</param>
+ /// <param name="name">The name of the child.</param>
+ /// <returns>
+ /// The full path of the child, or <c>null</c> if <paramref name="name"/> is not the name of a direct child
+ /// of <paramref name="parentPath"/>.
+ /// </returns>
+ public static string? GetChildPath(string parentPath, string name)
+ {
+ if (string.IsNullOrWhiteSpace(name) || name.Contains('\0', StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ // Rejects directory separators, and on Windows also volume separators, as those make the name more than a single segment.
+ if (!string.Equals(Path.GetFileName(name), name, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ var fullPath = Path.GetFullPath(Path.Combine(parentPath, name));
+ var fullParentPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parentPath));
+
+ // Catches the remaining relative names, "." and "..", which are valid single segments.
+ if (!string.Equals(Path.GetDirectoryName(fullPath), fullParentPath, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ // Windows strips trailing dots and spaces, so a name like "..." resolves to the parent directory itself
+ // and a name like "Movies." to a different child. Reject anything normalization did not leave intact.
+ return string.Equals(Path.GetFileName(fullPath), name, StringComparison.Ordinal) ? fullPath : null;
+ }
}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 2a6ea214b8..9028b0d6b8 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -107,6 +107,13 @@ namespace MediaBrowser.Controller.Library
Person? GetPerson(string name);
/// <summary>
+ /// Gets a Person, creating and persisting it if no item exists for the name yet.
+ /// </summary>
+ /// <param name="name">The name of the person.</param>
+ /// <returns>The person.</returns>
+ Person GetOrCreatePerson(string name);
+
+ /// <summary>
/// Finds the by path.
/// </summary>
/// <param name="path">The path.</param>
@@ -153,15 +160,6 @@ namespace MediaBrowser.Controller.Library
Year GetYear(int value);
/// <summary>
- /// Validate and refresh the People sub-set of the IBN.
- /// The items are stored in the db but not loaded into memory until actually requested by an operation.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken);
-
- /// <summary>
/// Reloads the root media folder.
/// </summary>
/// <param name="progress">The progress.</param>
@@ -709,6 +707,14 @@ namespace MediaBrowser.Controller.Library
/// <returns><c>true</c> if ignored, <c>false</c> otherwise.</returns>
bool IgnoreFile(FileSystemMetadata file, BaseItem parent);
+ /// <summary>
+ /// Gets the id a <see cref="Person"/> item for the name would have, without looking it up
+ /// or creating it.
+ /// </summary>
+ /// <param name="name">The name of the person.</param>
+ /// <returns>The item id for the name.</returns>
+ Guid GetPersonId(string name);
+
Guid GetStudioId(string name);
Guid GetGenreId(string name);
@@ -758,9 +764,9 @@ namespace MediaBrowser.Controller.Library
/// Returns the count of immediate children (non-recursive) for each parent.
/// </summary>
/// <param name="parentIds">The list of parent folder IDs.</param>
- /// <param name="userId">The user ID for access filtering.</param>
+ /// <param name="user">The user the counts are for, or null to count without a user's preferences.</param>
/// <returns>Dictionary mapping parent ID to child count.</returns>
- Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
+ Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user);
/// <summary>
/// Batch-fetches played and total counts for multiple folder items.
diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
index 6da398129a..be75117b6f 100644
--- a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
+++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
@@ -17,7 +17,8 @@ namespace MediaBrowser.Controller.LibraryTaskScheduler;
/// </summary>
public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable
{
- private const int CleanupGracePeriod = 60;
+ private static readonly TimeSpan _cleanupGracePeriod = TimeSpan.FromSeconds(60);
+
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger;
private readonly IServerConfigurationManager _serverConfigurationManager;
@@ -31,6 +32,8 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
private readonly Lock _taskLock = new();
private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
+ private readonly CancellationTokenSource _disposeTokenSource = new();
+ private readonly TimeSpan _gracePeriod;
private volatile int _workCounter;
private Task? _cleanupTask;
@@ -46,10 +49,34 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
IHostApplicationLifetime hostApplicationLifetime,
ILogger<LimitedConcurrencyLibraryScheduler> logger,
IServerConfigurationManager serverConfigurationManager)
+ : this(hostApplicationLifetime, logger, serverConfigurationManager, _cleanupGracePeriod)
+ {
+ }
+
+ internal LimitedConcurrencyLibraryScheduler(
+ IHostApplicationLifetime hostApplicationLifetime,
+ ILogger<LimitedConcurrencyLibraryScheduler> logger,
+ IServerConfigurationManager serverConfigurationManager,
+ TimeSpan gracePeriod)
{
_hostApplicationLifetime = hostApplicationLifetime;
_logger = logger;
_serverConfigurationManager = serverConfigurationManager;
+ _gracePeriod = gracePeriod;
+ }
+
+ /// <summary>
+ /// Gets the number of runners the scheduler currently keeps alive.
+ /// </summary>
+ internal int ActiveRunnerCount
+ {
+ get
+ {
+ lock (_taskLock)
+ {
+ return _taskRunners.Count;
+ }
+ }
}
private void ScheduleTaskCleanup()
@@ -68,31 +95,65 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
async Task RunCleanupTask()
{
- _logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod);
- await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false);
- if (_disposed)
+ while (true)
{
- _logger.LogDebug("Abort cleaning up, already disposed.");
- return;
- }
+ _logger.LogDebug("Schedule cleanup task in {CleanupGracePeriod}.", _gracePeriod);
+ try
+ {
+ await Task.Delay(_gracePeriod, _disposeTokenSource.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogDebug("Abort cleaning up, already disposed.");
+ return;
+ }
- lock (_taskLock)
- {
- if (_tasks.Reader.Count > 0 || _workCounter > 0)
+ if (_disposed)
{
- _logger.LogDebug("Delay cleanup task, operations still running.");
- // tasks are still there so its still in use. Reschedule cleanup task.
- // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended.
- _cleanupTask = RunCleanupTask();
+ _logger.LogDebug("Abort cleaning up, already disposed.");
return;
}
+
+ CancellationTokenSource[] runners;
+ lock (_taskLock)
+ {
+ if (_tasks.Reader.Count > 0 || _workCounter > 0)
+ {
+ _logger.LogDebug("Delay cleanup task, operations still running.");
+ // tasks are still there so its still in use. Wait another grace period.
+ // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended.
+ continue;
+ }
+
+ runners = [.. _taskRunners.Keys];
+
+ // Retire the runners before they are told to stop: an operation starting while
+ // they wind down must spawn its own instead of counting these towards the fanout.
+ _taskRunners.Clear();
+
+ // Hand the next operation the ability to schedule a cleanup again. Without this
+ // the very first cleanup would be the only one that ever runs.
+ _cleanupTask = null;
+ }
+
+ _logger.LogDebug("Cleanup runners.");
+ await StopRunners(runners).ConfigureAwait(false);
+ return;
}
+ }
+ }
- _logger.LogDebug("Cleanup runners.");
- foreach (var item in _taskRunners.ToArray())
+ private static async Task StopRunners(CancellationTokenSource[] runners)
+ {
+ foreach (var runner in runners)
+ {
+ try
{
- await item.Key.CancelAsync().ConfigureAwait(false);
- _taskRunners.Remove(item.Key);
+ await runner.CancelAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ // The runner already stopped on its own and disposed its stop source.
}
}
}
@@ -127,12 +188,17 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
{
var stopToken = new CancellationTokenSource();
var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping);
+
+ // Keyed on its own stop source, because cancelling that is what reaches the linked
+ // source the runner waits on. Cancellation does not travel the other way.
+ // Started without the runner's own token: a task cancelled before it is scheduled
+ // never runs its body, so it would never take itself out of _taskRunners again.
_taskRunners.Add(
- combinedSource,
+ stopToken,
Task.Factory.StartNew(
ItemWorker,
- (combinedSource, stopToken),
- combinedSource.Token,
+ (stopToken, combinedSource),
+ CancellationToken.None,
TaskCreationOptions.PreferFairness,
TaskScheduler.Default));
}
@@ -145,7 +211,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_deadlockDetector.Value = stopToken.TaskStop;
try
{
- while (!stopToken.GlobalStop.Token.IsCancellationRequested)
+ while (!stopToken.GlobalStop.IsCancellationRequested)
{
var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
try
@@ -162,15 +228,24 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
}
}
}
- catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested)
+ catch (OperationCanceledException) when (stopToken.GlobalStop.IsCancellationRequested)
{
// thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose.
}
+ catch (ChannelClosedException)
+ {
+ // the scheduler was disposed and will not hand out any more work.
+ }
finally
{
_logger.LogDebug("Cleanup Runner'.");
_deadlockDetector.Value = default!;
- _taskRunners.Remove(stopToken.TaskStop);
+
+ lock (_taskLock)
+ {
+ _taskRunners.Remove(stopToken.TaskStop);
+ }
+
stopToken.GlobalStop.Dispose();
stopToken.TaskStop.Dispose();
}
@@ -195,7 +270,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
finally
{
item.Progress.Report(100);
- item.Done.SetResult();
+ item.Done.TrySetResult();
}
}
@@ -285,16 +360,33 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_disposed = true;
_tasks.Writer.Complete();
- foreach (var item in _taskRunners)
+
+ // Nobody is left to run these, so release whoever is waiting on them.
+ while (_tasks.Reader.TryRead(out var item))
{
- await item.Key.CancelAsync().ConfigureAwait(false);
+ item.Done.TrySetResult();
}
- if (_cleanupTask is not null)
+ CancellationTokenSource[] runners;
+ Task? cleanupTask;
+ lock (_taskLock)
{
- await _cleanupTask.ConfigureAwait(false);
- _cleanupTask?.Dispose();
+ runners = [.. _taskRunners.Keys];
+ _taskRunners.Clear();
+ cleanupTask = _cleanupTask;
}
+
+ await StopRunners(runners).ConfigureAwait(false);
+
+ // Cuts the grace period short instead of holding up shutdown for the rest of it.
+ await _disposeTokenSource.CancelAsync().ConfigureAwait(false);
+
+ if (cleanupTask is not null)
+ {
+ await cleanupTask.ConfigureAwait(false);
+ }
+
+ _disposeTokenSource.Dispose();
}
private class TaskQueueItem
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index 06188ad511..73cdf18e91 100644
--- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj
+++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
@@ -18,7 +18,6 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="BitFaster.Caching" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
</ItemGroup>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index f0c2580e08..a0f4087395 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -442,7 +442,8 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|| IsHdr10Plus(state.VideoStream)
|| IsDoviWithHdr10Bl(state.VideoStream)
- || state.VideoStream.VideoRangeType == VideoRangeType.HLG);
+ || state.VideoStream.VideoRangeType == VideoRangeType.HLG
+ || state.VideoStream.VideoRangeType == VideoRangeType.DOVIInvalid);
}
private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
@@ -695,7 +696,11 @@ namespace MediaBrowser.Controller.MediaEncoding
"ogg" or "oga" or "ogv" or "webm" or "webma" => "opus",
"m4a" or "m4b" or "mp4" or "mov" or "mkv" or "mka" => "aac",
"ts" or "avi" or "flv" or "f4v" or "swf" => "mp3",
- _ => inferredCodec
+ // Containers that share their name with the codec they carry.
+ "aac" or "ac3" or "alac" or "dts" or "eac3" or "flac" or "mp2" or "mp3" or "opus" or "truehd" or "vorbis" => inferredCodec,
+ // Anything else - manifests such as m3u8/mpd in particular - names a container that
+ // is not an audio codec. Never hand that name to ffmpeg as an encoder.
+ _ => "aac"
};
}
@@ -1386,7 +1391,8 @@ namespace MediaBrowser.Controller.MediaEncoding
or VideoRangeType.DOVIWithEL
or VideoRangeType.DOVIWithHDR10Plus
or VideoRangeType.DOVIWithELHDR10Plus
- or VideoRangeType.DOVIInvalid;
+ || (rangeType == VideoRangeType.DOVIInvalid
+ && string.Equals(stream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)); // invalid may be hlg now
}
public static bool IsDovi(MediaStream stream)
@@ -1396,7 +1402,8 @@ namespace MediaBrowser.Controller.MediaEncoding
return IsDoviWithHdr10Bl(stream)
|| (rangeType is VideoRangeType.DOVI
or VideoRangeType.DOVIWithHLG
- or VideoRangeType.DOVIWithSDR);
+ or VideoRangeType.DOVIWithSDR
+ or VideoRangeType.DOVIInvalid);
}
public static bool IsHdr10Plus(MediaStream stream)
@@ -1416,7 +1423,8 @@ namespace MediaBrowser.Controller.MediaEncoding
private static DynamicHdrMetadataRemovalPlan ShouldRemoveDynamicHdrMetadata(EncodingJobInfo state)
{
var videoStream = state.VideoStream;
- if (videoStream.VideoRange is not VideoRange.HDR)
+ if (videoStream.VideoRange is not VideoRange.HDR
+ && videoStream.VideoRangeType != VideoRangeType.DOVIInvalid)
{
return DynamicHdrMetadataRemovalPlan.None;
}
diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs
index d57f1fc893..8ddf93e3e0 100644
--- a/MediaBrowser.Controller/Persistence/IItemCountService.cs
+++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs
@@ -80,7 +80,7 @@ public interface IItemCountService
/// Batch-fetches child counts for multiple parent folders.
/// </summary>
/// <param name="parentIds">The list of parent folder IDs.</param>
- /// <param name="userId">The user ID for access filtering.</param>
+ /// <param name="user">The user the counts are for, or null to count without a user's preferences.</param>
/// <returns>Dictionary mapping parent ID to child count.</returns>
- Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
+ Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user);
}
diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs
index 6060d051a5..f8e0bf4ed9 100644
--- a/MediaBrowser.Controller/Providers/DirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/DirectoryService.cs
@@ -5,13 +5,19 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Threading;
using MediaBrowser.Model.IO;
namespace MediaBrowser.Controller.Providers
{
public class DirectoryService : IDirectoryService
{
- // TODO make static and switch to FastConcurrentLru.
+ // TODO replace with one shared bounded cache.
+ private const int MaxCachedRecords = 100_000;
+ private const int AccessIntervalMs = 1_000;
+ // Timeout cache if no access for 5 minutes.
+ private const int IdleTimeoutMs = 5 * 60 * 1_000;
+
private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new(StringComparer.Ordinal);
@@ -20,6 +26,12 @@ namespace MediaBrowser.Controller.Providers
private readonly IFileSystem _fileSystem;
+ // ConcurrentDictionary.Count locks the dictionary, so keep an estimated counter.
+ // Concurrent factory runs can overcount and a clear racing an add can undercount,
+ // it only has to be roughly right.
+ private int _recordCount;
+ private long _lastAccess = Environment.TickCount64;
+
public DirectoryService(IFileSystem fileSystem)
{
_fileSystem = fileSystem;
@@ -27,20 +39,26 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata[] GetFileSystemEntries(string path)
{
+ DropCacheIfIdleOrFull();
+
return _cache.GetOrAdd(
path,
- static (p, fileSystem) =>
+ static (p, state) =>
{
+ FileSystemMetadata[] entries;
try
{
- return fileSystem.GetFileSystemEntries(p).ToArray();
+ entries = state.FileSystem.GetFileSystemEntries(p).ToArray();
}
catch (DirectoryNotFoundException)
{
- return [];
+ entries = [];
}
+
+ Interlocked.Add(ref state.Service._recordCount, entries.Length + 1);
+ return entries;
},
- _fileSystem);
+ (FileSystem: _fileSystem, Service: this));
}
public List<FileSystemMetadata> GetDirectories(string path)
@@ -89,13 +107,18 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata? GetFileSystemEntry(string path)
{
+ DropCacheIfIdleOrFull();
+
if (!_fileCache.TryGetValue(path, out var result))
{
var file = _fileSystem.GetFileSystemInfo(path);
if (file?.Exists ?? false)
{
result = file;
- _fileCache.TryAdd(path, result);
+ if (_fileCache.TryAdd(path, result))
+ {
+ Interlocked.Increment(ref _recordCount);
+ }
}
}
@@ -107,32 +130,96 @@ namespace MediaBrowser.Controller.Providers
public IReadOnlyList<string> GetFilePaths(string path, bool clearCache)
{
- if (clearCache)
+ if (clearCache && _filePathCache.TryRemove(path, out var cached))
{
- _filePathCache.TryRemove(path, out _);
+ Interlocked.Add(ref _recordCount, -(cached.Count + 1));
}
+ DropCacheIfIdleOrFull();
+
var filePaths = _filePathCache.GetOrAdd(
path,
- static (p, fileSystem) =>
+ static (p, state) =>
{
+ List<string> filePaths;
try
{
- return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList();
+ filePaths = state.FileSystem.GetFilePaths(p).OrderBy(x => x).ToList();
}
catch (DirectoryNotFoundException)
{
- return [];
+ filePaths = [];
}
+
+ Interlocked.Add(ref state.Service._recordCount, filePaths.Count + 1);
+ return filePaths;
},
- _fileSystem);
+ (FileSystem: _fileSystem, Service: this));
return filePaths;
}
+ public void Invalidate(string path)
+ {
+ Forget(path);
+
+ var parent = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(parent))
+ {
+ Forget(parent);
+ }
+ }
+
+ public void Move(string source, string destination)
+ {
+ Directory.Move(source, destination);
+
+ Invalidate(source);
+ Invalidate(destination);
+ }
+
public bool IsAccessible(string path)
{
return _fileSystem.GetFileSystemEntryPaths(path).Any();
}
+
+ private void DropCacheIfIdleOrFull()
+ {
+ var nowMs = Environment.TickCount64;
+ var idleMs = nowMs - _lastAccess;
+
+ if (idleMs >= IdleTimeoutMs || _recordCount >= MaxCachedRecords)
+ {
+ _cache.Clear();
+ _fileCache.Clear();
+ _filePathCache.Clear();
+ _recordCount = 0;
+ _lastAccess = nowMs;
+ return;
+ }
+
+ if (idleMs >= AccessIntervalMs)
+ {
+ _lastAccess = nowMs;
+ }
+ }
+
+ private void Forget(string path)
+ {
+ if (_cache.TryRemove(path, out var entries))
+ {
+ Interlocked.Add(ref _recordCount, -(entries.Length + 1));
+ }
+
+ if (_fileCache.TryRemove(path, out _))
+ {
+ Interlocked.Decrement(ref _recordCount);
+ }
+
+ if (_filePathCache.TryRemove(path, out var filePaths))
+ {
+ Interlocked.Add(ref _recordCount, -(filePaths.Count + 1));
+ }
+ }
}
}
diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs
index 8a3fa33da3..3a943d5f0c 100644
--- a/MediaBrowser.Controller/Providers/IDirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs
@@ -23,6 +23,19 @@ namespace MediaBrowser.Controller.Providers
IReadOnlyList<string> GetFilePaths(string path, bool clearCache);
+ /// <summary>
+ /// Forgets what is cached about a path and about the directory containing it.
+ /// </summary>
+ /// <param name="path">The file or directory path that changed.</param>
+ void Invalidate(string path);
+
+ /// <summary>
+ /// Moves a directory and forgets what is cached about both paths.
+ /// </summary>
+ /// <param name="source">The directory to move.</param>
+ /// <param name="destination">The path to move the directory to.</param>
+ void Move(string source, string destination);
+
bool IsAccessible(string path);
}
}
diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs
index c11c65c334..9acff745b9 100644
--- a/MediaBrowser.Controller/Session/ISessionManager.cs
+++ b/MediaBrowser.Controller/Session/ISessionManager.cs
@@ -238,23 +238,26 @@ namespace MediaBrowser.Controller.Session
/// <summary>
/// Adds the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- void AddAdditionalUser(string sessionId, Guid userId);
+ void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
/// <summary>
/// Removes the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- void RemoveAdditionalUser(string sessionId, Guid userId);
+ void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
/// <summary>
/// Reports the now viewing item.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="itemId">The item identifier.</param>
- void ReportNowViewingItem(string sessionId, string itemId);
+ void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
/// <summary>
/// Authenticates the new session.
@@ -268,9 +271,10 @@ namespace MediaBrowser.Controller.Session
/// <summary>
/// Reports the capabilities.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="capabilities">The capabilities.</param>
- void ReportCapabilities(string sessionId, ClientCapabilities capabilities);
+ void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities);
/// <summary>
/// Reports the transcoding information.
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
index eb38eeb503..f4fab29800 100644
--- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
@@ -501,7 +501,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
{
// Client, that was buffering, resumed playback but did not update others in time.
delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond;
- delayTicks = Math.Max(delayTicks, context.DefaultPing);
+ delayTicks = Math.Max(delayTicks, TimeSpan.FromMilliseconds(context.DefaultPing).Ticks);
context.LastActivity = currentTime.AddTicks(delayTicks);
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
index 9326864d78..258b92e4d9 100644
--- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -157,7 +157,10 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// </summary>
public void RestoreSortedPlaylist()
{
- if (PlayingItemIndex != NoPlayingItemIndex)
+ // The shuffled playlist is only populated while the shuffle mode is active, so there is
+ // nothing to map back when the playlist is already sorted. Guarding on its contents keeps
+ // a redundant request for the sorted mode from indexing an empty list.
+ if (PlayingItemIndex != NoPlayingItemIndex && _shuffledPlaylist.Count > 0)
{
var playingItem = _shuffledPlaylist[PlayingItemIndex];
PlayingItemIndex = _sortedPlaylist.IndexOf(playingItem);
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 0ddd378352..f64fd73763 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -1152,6 +1152,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
{
process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
}
+ catch (InvalidOperationException)
+ {
+ // The process finished before its priority could be lowered. That says nothing
+ // about whether the platform allows it, so keep the capability for the next one.
+ }
catch (Exception ex)
{
_canSetProcessPriority = false;
@@ -1361,12 +1366,20 @@ namespace MediaBrowser.MediaEncoding.Encoder
return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction;
}
- private sealed class ProcessWrapper : IDisposable
+ internal sealed class ProcessWrapper : IDisposable
{
private readonly MediaEncoder _mediaEncoder;
+ // The exit event is raised on the thread pool, so it writes the state below while the
+ // caller that started the process is reading it.
+ private readonly Lock _exitLock = new();
+
private bool _disposed = false;
+ private bool _hasExited;
+
+ private int? _exitCode;
+
public ProcessWrapper(Process process, MediaEncoder mediaEncoder)
{
Process = process;
@@ -1376,49 +1389,84 @@ namespace MediaBrowser.MediaEncoding.Encoder
public Process Process { get; }
- public bool HasExited { get; private set; }
+ // The exit event can lag behind the wait that returned, so ask the process rather than
+ // report one that has exited as still running.
+ public bool HasExited => ReadExitState().HasExited;
+
+ // As above: rather than report no exit code for a process that has one.
+ public int? ExitCode => ReadExitState().ExitCode;
- public int? ExitCode { get; private set; }
+ private (bool HasExited, int? ExitCode) ReadExitState()
+ {
+ lock (_exitLock)
+ {
+ if (!_hasExited && !_disposed)
+ {
+ try
+ {
+ if (Process.HasExited)
+ {
+ _hasExited = true;
+ _exitCode = Process.ExitCode;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // No process is associated with this object, or it was disposed from
+ // under us - ObjectDisposedException derives from this one.
+ }
+ }
+
+ return (_hasExited, _exitCode);
+ }
+ }
private void OnProcessExited(object sender, EventArgs e)
{
var process = (Process)sender;
- HasExited = true;
-
- try
- {
- ExitCode = process.ExitCode;
- }
- catch
+ lock (_exitLock)
{
+ _hasExited = true;
+
+ try
+ {
+ _exitCode = process.ExitCode;
+ }
+ catch
+ {
+ }
}
- DisposeProcess(process);
+ // Only stop tracking it. The caller that started the process still holds it to read
+ // its output and its exit code, so disposing it here handed whoever was quickest to
+ // exit - an ffprobe on a file it rejects outright - an ObjectDisposedException.
+ Untrack();
}
- private void DisposeProcess(Process process)
+ private void Untrack()
{
lock (_mediaEncoder._runningProcessesLock)
{
_mediaEncoder._runningProcesses.Remove(this);
}
-
- process.Dispose();
}
public void Dispose()
{
- if (!_disposed)
+ lock (_exitLock)
{
- if (Process is not null)
+ if (_disposed)
{
- Process.Exited -= OnProcessExited;
- DisposeProcess(Process);
+ return;
}
+
+ _disposed = true;
}
- _disposed = true;
+ Process.Exited -= OnProcessExited;
+ Untrack();
+ Process.Dispose();
}
}
}
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index ab8d5dd5b2..4799ed5410 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -26,6 +26,8 @@ namespace MediaBrowser.Model.Dlna
internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons;
internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported;
+ private const string ManifestContainers = "hls,applehttp,dash";
+
private readonly ILogger _logger;
private readonly ITranscoderSupport _transcoderSupport;
private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"];
@@ -718,6 +720,14 @@ namespace MediaBrowser.Model.Dlna
isEligibleForDirectPlay = false;
}
+ // A manifest is not a byte stream, so it cannot be handed to the client as one. The variant
+ // and segment URIs inside it are relative to the origin and do not resolve against the
+ // Jellyfin url the client would fetch it from.
+ if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container))
+ {
+ isEligibleForDirectPlay = false;
+ }
+
if (bitrateLimitExceeded)
{
transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit;
diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs
index f057714bea..67af843626 100644
--- a/MediaBrowser.Model/Entities/MediaStream.cs
+++ b/MediaBrowser.Model/Entities/MediaStream.cs
@@ -810,6 +810,11 @@ namespace MediaBrowser.Model.Entities
return (VideoRange.Unknown, VideoRangeType.Unknown);
}
+ var isPq = string.Equals(ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase);
+ var isHlg = string.Equals(ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase);
+ // Invalid DV only retains HDR when the base layer explicitly signals PQ or HLG.
+ var baseVideoRange = isPq || isHlg ? VideoRange.HDR : VideoRange.SDR;
+
var codecTag = CodecTag;
var dvProfile = DvProfile;
var rpuPresentFlag = RpuPresentFlag == 1;
@@ -834,7 +839,7 @@ namespace MediaBrowser.Model.Entities
4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG),
2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR),
// Out of Dolby Spec files should be marked as invalid
- _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid)
+ _ => (baseVideoRange, VideoRangeType.DOVIInvalid)
},
7 => (VideoRange.HDR, VideoRangeType.DOVIWithEL),
10 => dvBlCompatId switch
@@ -844,11 +849,26 @@ namespace MediaBrowser.Model.Entities
2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR),
4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG),
// Out of Dolby Spec files should be marked as invalid
- _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid)
+ _ => (baseVideoRange, VideoRangeType.DOVIInvalid)
},
_ => (VideoRange.SDR, VideoRangeType.SDR)
};
+ var expectedTransfer = dvRangeSet.Item2 switch
+ {
+ VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithEL => "smpte2084",
+ VideoRangeType.DOVIWithHLG => "arib-std-b67",
+ _ => null
+ };
+
+ if (expectedTransfer is not null
+ && (!string.Equals(ColorSpace, "bt2020nc", StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(ColorTransfer, expectedTransfer, StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(ColorPrimaries, "bt2020", StringComparison.OrdinalIgnoreCase)))
+ {
+ return (baseVideoRange, VideoRangeType.DOVIInvalid);
+ }
+
if (Hdr10PlusPresentFlag == true)
{
return dvRangeSet.Item2 switch
@@ -862,13 +882,11 @@ namespace MediaBrowser.Model.Entities
return dvRangeSet;
}
- var colorTransfer = ColorTransfer;
-
- if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase))
+ if (isPq)
{
return Hdr10PlusPresentFlag == true ? (VideoRange.HDR, VideoRangeType.HDR10Plus) : (VideoRange.HDR, VideoRangeType.HDR10);
}
- else if (string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase))
+ else if (isHlg)
{
return (VideoRange.HDR, VideoRangeType.HLG);
}
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index af31e373ef..a19262c3a7 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -32,6 +32,7 @@ public class LyricManager : ILyricManager
private readonly IFileSystem _fileSystem;
private readonly ILibraryMonitor _libraryMonitor;
private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IDirectoryService _directoryService;
private readonly ILyricProvider[] _lyricProviders;
private readonly ILyricParser[] _lyricParsers;
@@ -43,6 +44,7 @@ public class LyricManager : ILyricManager
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
/// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
/// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
public LyricManager(
@@ -50,6 +52,7 @@ public class LyricManager : ILyricManager
IFileSystem fileSystem,
ILibraryMonitor libraryMonitor,
IMediaSourceManager mediaSourceManager,
+ IDirectoryService directoryService,
IEnumerable<ILyricProvider> lyricProviders,
IEnumerable<ILyricParser> lyricParsers)
{
@@ -57,6 +60,7 @@ public class LyricManager : ILyricManager
_fileSystem = fileSystem;
_libraryMonitor = libraryMonitor;
_mediaSourceManager = mediaSourceManager;
+ _directoryService = directoryService;
_lyricProviders = lyricProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
@@ -250,6 +254,8 @@ public class LyricManager : ILyricManager
{
_libraryMonitor.ReportFileSystemChangeComplete(path, false);
}
+
+ _directoryService.Invalidate(path);
}
return audio.RefreshMetadata(CancellationToken.None);
@@ -446,6 +452,8 @@ public class LyricManager : ILyricManager
await stream.CopyToAsync(fs).ConfigureAwait(false);
}
+ _directoryService.Invalidate(savePath);
+
return;
}
catch (Exception ex)
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index fbd9e5435e..e7b15305b3 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -1143,16 +1143,21 @@ namespace MediaBrowser.Providers.Manager
return;
}
- _refreshQueue.Enqueue((itemId, options), priority);
-
+ // PriorityQueue is not thread safe and the processor dequeues concurrently, so every
+ // touch of the queue takes the lock.
lock (_refreshQueueLock)
{
- if (!_isProcessingRefreshQueue)
+ _refreshQueue.Enqueue((itemId, options), priority);
+
+ if (_isProcessingRefreshQueue)
{
- _isProcessingRefreshQueue = true;
- Task.Run(StartProcessingRefreshQueue);
+ return;
}
+
+ _isProcessingRefreshQueue = true;
}
+
+ Task.Run(StartProcessingRefreshQueue);
}
private async Task StartProcessingRefreshQueue()
@@ -1161,17 +1166,33 @@ namespace MediaBrowser.Providers.Manager
if (_disposed)
{
+ lock (_refreshQueueLock)
+ {
+ _isProcessingRefreshQueue = false;
+ }
+
return;
}
var cancellationToken = _disposeCancellationTokenSource.Token;
libraryManager.ClearIgnoreRuleCache();
- while (_refreshQueue.TryDequeue(out var refreshItem, out _))
+
+ while (true)
{
- if (_disposed)
+ (Guid ItemId, MetadataRefreshOptions RefreshOptions) refreshItem;
+
+ // Dequeueing and standing down happen under one lock, otherwise a refresh queued
+ // just after the queue ran dry would see a processor that has already stopped.
+ lock (_refreshQueueLock)
{
- return;
+ if (_disposed
+ || cancellationToken.IsCancellationRequested
+ || !_refreshQueue.TryDequeue(out refreshItem, out _))
+ {
+ _isProcessingRefreshQueue = false;
+ break;
+ }
}
try
@@ -1188,19 +1209,21 @@ namespace MediaBrowser.Providers.Manager
await task.ConfigureAwait(false);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
- break;
+ // Shutting down: the next pass sees the token and stands the processor down.
+ continue;
}
catch (Exception ex)
{
+ // Includes a provider that cancelled for its own reasons, such as an HTTP
+ // timeout, which must not stop the queue draining.
_logger.LogError(ex, "Error refreshing item");
}
}
- lock (_refreshQueueLock)
+ if (!_disposed)
{
- _isProcessingRefreshQueue = false;
libraryManager.ClearIgnoreRuleCache();
}
}
diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
index 6f9d5f19da..ecb6e5d990 100644
--- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
+++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
@@ -231,10 +231,28 @@ namespace MediaBrowser.Providers.MediaInfo
return Array.Empty<ExternalPathParserResult>();
}
+ // VobSub .sub payloads only carry per-track language metadata when read via
+ // their paired .idx file, so probe the .idx instead and skip the .sub. Pairing
+ // requires the same directory (ffprobe can't resolve a split pair) and an
+ // ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive
+ // filesystems, so a looser match could suppress a .sub with no working .idx).
+ // An .idx file with no paired .sub cannot be probed at all, so it is left out
+ // entirely rather than surfaced (which would otherwise fail every probe and,
+ // since the .idx would keep "existing" from Jellyfin's point of view, prevent
+ // stale subtitle stream metadata from ever being cleared once the .sub is gone).
+ HashSet<string>? pairedVobSubKeys = _type == DlnaProfileType.Subtitle
+ ? GetPairedVobSubKeys(files)
+ : null;
+
var externalPathInfos = new List<ExternalPathParserResult>();
ReadOnlySpan<char> prefix = video.FileNameWithoutExtension;
foreach (var file in files)
{
+ if (IsSuppressedVobSubFile(file, pairedVobSubKeys))
+ {
+ continue;
+ }
+
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
if (fileNameWithoutExtension.Length >= prefix.Length
&& prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
@@ -305,6 +323,77 @@ namespace MediaBrowser.Providers.MediaInfo
}
/// <summary>
+ /// Determines whether a candidate file is part of a VobSub .idx/.sub pair that
+ /// should be resolved to only its .idx file, or an .idx file with no paired .sub
+ /// that cannot be probed at all.
+ /// </summary>
+ /// <param name="file">The full path to the candidate file.</param>
+ /// <param name="pairedVobSubKeys">The set of pairing keys with both an .idx and .sub present, or null if not applicable.</param>
+ /// <returns><c>true</c> if the file should be suppressed; otherwise, <c>false</c>.</returns>
+ private static bool IsSuppressedVobSubFile(string file, HashSet<string>? pairedVobSubKeys)
+ {
+ if (pairedVobSubKeys is null)
+ {
+ return false;
+ }
+
+ var extension = Path.GetExtension(file.AsSpan());
+ if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
+ {
+ // A paired .idx exists; probe it instead of the .sub payload.
+ return pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
+ }
+
+ if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
+ {
+ // Without its .sub payload, the .idx cannot be probed for any data.
+ return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Builds the set of directory+basename keys that have both an .idx and a .sub
+ /// file present, in a single pass over the candidate files.
+ /// </summary>
+ /// <param name="files">The candidate files to search.</param>
+ /// <returns>The set of pairing keys with both an .idx and .sub present.</returns>
+ private static HashSet<string> GetPairedVobSubKeys(IEnumerable<string> files)
+ {
+ var idxKeys = new HashSet<string>(StringComparer.Ordinal);
+ var subKeys = new HashSet<string>(StringComparer.Ordinal);
+ foreach (var file in files)
+ {
+ var extension = Path.GetExtension(file.AsSpan());
+ if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
+ {
+ idxKeys.Add(GetVobSubPairingKey(file));
+ }
+ else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
+ {
+ subKeys.Add(GetVobSubPairingKey(file));
+ }
+ }
+
+ idxKeys.IntersectWith(subKeys);
+ return idxKeys;
+ }
+
+ /// <summary>
+ /// Builds a directory+basename key used to pair a VobSub .idx file with its .sub
+ /// payload only when both live in the same directory.
+ /// </summary>
+ /// <param name="file">The full path to the file.</param>
+ /// <returns>A key combining the containing directory and file name without extension.</returns>
+ private static string GetVobSubPairingKey(string file)
+ {
+ var directory = Path.GetDirectoryName(file) ?? string.Empty;
+ var baseName = Path.GetFileNameWithoutExtension(file);
+ return Path.Combine(directory, baseName);
+ }
+
+ /// <summary>
/// Returns the media info of the given file.
/// </summary>
/// <param name="path">The path to the file.</param>
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html
index dec21d1b42..d485fe555a 100644
--- a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html
@@ -7,7 +7,6 @@
<div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select">
<div data-role="content">
<div class="content-primary">
- <img id="listenBrainzLogo" alt="ListenBrainz" style="max-width:240px;display:block;margin:0 auto 1em;" />
<h1>ListenBrainz</h1>
<p>Get similar artist recommendations from ListenBrainz Labs.</p>
<form class="configForm">
@@ -18,12 +17,12 @@
<div class="selectContainer">
<label class="selectLabel" for="algorithm">Similarity Algorithm</label>
<select is="emby-select" id="algorithm" class="emby-select-withcolor">
- <option value="0" selected>~5 years / 1825 days (Recommended)</option>
- <option value="1">~5 years / 1800 days</option>
- <option value="2">~20 years / 7500 days</option>
- <option value="3">~20 years / 7500 days (high contribution)</option>
- <option value="4">~25 years / 9000 days</option>
- <option value="5">~75 days (recent)</option>
+ <option value="SessionBased1825Days" selected>~5 years / 1825 days (Recommended)</option>
+ <option value="SessionBased1800Days">~5 years / 1800 days</option>
+ <option value="SessionBased7500Days">~20 years / 7500 days</option>
+ <option value="SessionBased7500DaysHighContribution">~20 years / 7500 days (high contribution)</option>
+ <option value="SessionBased9000Days">~25 years / 9000 days</option>
+ <option value="SessionBased75Days">~75 days (recent)</option>
</select>
<div class="fieldDescription">The algorithm used for artist similarity calculation.</div>
</div>
@@ -52,13 +51,14 @@
</div>
<script type="text/javascript">
var ListenBrainzPluginConfig = {
- uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e"
+ uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e",
+ defaultAlgorithm: "SessionBased1825Days"
};
document.querySelector('.configPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
- document.querySelector('#listenBrainzLogo').src = ApiClient.getUrl('web/ConfigurationPage', { name: 'ListenBrainzLogo' });
+
ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) {
var labsServer = document.querySelector('#labsServer');
labsServer.value = config.LabsServer;
@@ -67,7 +67,13 @@
cancelable: false
}));
- document.querySelector('#algorithm').value = config.Algorithm;
+ // The API serialises the algorithm as its enum name, so an unknown value here
+ // means a config written by an older build; fall back to the default.
+ var algorithm = document.querySelector('#algorithm');
+ algorithm.value = config.Algorithm;
+ if (!algorithm.value) {
+ algorithm.value = ListenBrainzPluginConfig.defaultAlgorithm;
+ }
var rateLimit = document.querySelector('#rateLimit');
rateLimit.value = config.RateLimit;
@@ -93,7 +99,7 @@
ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) {
config.LabsServer = document.querySelector('#labsServer').value;
- config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10);
+ config.Algorithm = document.querySelector('#algorithm').value;
config.RateLimit = document.querySelector('#rateLimit').value;
config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10);
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
index b3a67189bb..c94a6455bc 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
@@ -128,6 +128,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <summary>
/// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching.
/// </summary>
- public int SimilarItemsCacheDays { get; set; } = 7;
+ public int SimilarItemsCacheDays { get; set; } = 90;
}
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs
index 5206de78ce..6a3c72d2fa 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs
@@ -58,7 +58,7 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
}
var providerName = MetadataProvider.Tmdb.ToString();
- var page = 0;
+ var page = 1;
var totalPages = 1;
while (page <= totalPages && !cancellationToken.IsCancellationRequested)
@@ -67,12 +67,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
try
{
(pageResults, totalPages) = await _tmdbClientManager
- .GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
+ .GetMovieRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page);
+ _logger.LogWarning(ex, "Failed to get recommended movies from TMDb for {TmdbId} page {Page}", tmdbId, page);
yield break;
}
@@ -81,12 +81,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
yield break;
}
- foreach (var similar in pageResults)
+ foreach (var recommendation in pageResults)
{
yield return new SimilarItemReference
{
ProviderName = providerName,
- ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture)
+ ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture)
};
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs
index c85718b993..40c7de05ad 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs
@@ -67,12 +67,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series>
try
{
(pageResults, totalPages) = await _tmdbClientManager
- .GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
+ .GetSeriesRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page);
+ _logger.LogWarning(ex, "Failed to get recommended TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page);
yield break;
}
@@ -81,12 +81,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series>
yield break;
}
- foreach (var similar in pageResults)
+ foreach (var recommendation in pageResults)
{
yield return new SimilarItemReference
{
ProviderName = providerName,
- ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture)
+ ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture)
};
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index 5379796465..fb3e5ee92b 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -25,16 +25,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
{
private const int CacheDurationInHours = 1;
- private readonly IMemoryCache _memoryCache;
+ // Sized in TMDb records - see EstimateSize - rather than in responses, because the responses
+ // differ in weight by orders of magnitude.
+ private const int CacheSizeLimit = 100_000;
+
+ private readonly MemoryCache _memoryCache;
private readonly TMDbClient _tmDbClient;
/// <summary>
/// Initializes a new instance of the <see cref="TmdbClientManager"/> class.
/// </summary>
- /// <param name="memoryCache">An instance of <see cref="IMemoryCache"/>.</param>
- public TmdbClientManager(IMemoryCache memoryCache)
+ public TmdbClientManager()
{
- _memoryCache = memoryCache;
+ _memoryCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = CacheSizeLimit });
var apiKey = Plugin.Instance.Configuration.TmdbApiKey;
apiKey = string.IsNullOrEmpty(apiKey) ? TmdbUtils.ApiKey : apiKey;
@@ -78,7 +81,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (movie is not null)
{
- _memoryCache.Set(key, movie, TimeSpan.FromHours(CacheDurationInHours));
+ Cache(key, movie);
}
return movie;
@@ -112,7 +115,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (collection is not null)
{
- _memoryCache.Set(key, collection, TimeSpan.FromHours(CacheDurationInHours));
+ Cache(key, collection);
}
return collection;
@@ -152,7 +155,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (series is not null)
{
- _memoryCache.Set(key, series, TimeSpan.FromHours(CacheDurationInHours));
+ Cache(key, series);
}
return series;
@@ -208,7 +211,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (group is not null)
{
- _memoryCache.Set(key, group, TimeSpan.FromHours(CacheDurationInHours));
+ Cache(key, group);
}
return group;
@@ -244,7 +247,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (season is not null)
{
- _memoryCache.Set(key, season, TimeSpan.FromHours(CacheDurationInHours));
+ Cache(key, season);
}
return season;
@@ -296,7 +299,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (episode is not null)
{
- _memoryCache.Set(key, episode, TimeSpan.FromHours(CacheDurationInHours));
+ Cache(key, episode);
}
return episode;
@@ -328,7 +331,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (person is not null)
{
- _memoryCache.Set(key, person, TimeSpan.FromHours(CacheDurationInHours));
+ Cache(key, person);
}
return person;
@@ -366,7 +369,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (result is not null)
{
- _memoryCache.Set(key, result, TimeSpan.FromHours(CacheDurationInHours));
+ Cache(key, result);
}
return result;
@@ -397,7 +400,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (searchResults?.Results?.Count > 0)
{
- _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
+ CacheSearch(key, searchResults);
}
return searchResults?.Results;
@@ -425,7 +428,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (searchResults?.Results?.Count > 0)
{
- _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
+ CacheSearch(key, searchResults);
}
return searchResults?.Results;
@@ -468,7 +471,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (searchResults?.Results?.Count > 0)
{
- _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
+ CacheSearch(key, searchResults);
}
return searchResults?.Results;
@@ -498,26 +501,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (searchResults?.Results?.Count > 0)
{
- _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
+ CacheSearch(key, searchResults);
}
return searchResults?.Results;
}
/// <summary>
- /// Gets a single page of similar movies for a movie from the TMDb API.
+ /// Gets a single page of recommended movies for a movie from the TMDb API.
/// </summary>
/// <param name="tmdbId">The TMDb id of the movie.</param>
/// <param name="page">The page number to fetch (1-based).</param>
/// <param name="language">The language for results.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns>
- public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
+ /// <returns>A tuple containing the list of recommended movies and the total number of pages available.</returns>
+ public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
- .GetMovieSimilarAsync(tmdbId, language, page, cancellationToken)
+ .GetMovieRecommendationsAsync(tmdbId, language, page, cancellationToken)
.ConfigureAwait(false);
if (searchResults?.Results is null || searchResults.Results.Count == 0)
@@ -529,19 +532,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
- /// Gets a single page of similar TV shows for a series from the TMDb API.
+ /// Gets a single page of recommended TV shows for a series from the TMDb API.
/// </summary>
/// <param name="tmdbId">The TMDb id of the TV show.</param>
/// <param name="page">The page number to fetch (1-based).</param>
/// <param name="language">The language for results.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns>
- public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
+ /// <returns>A tuple containing the list of recommended TV shows and the total number of pages available.</returns>
+ public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
- .GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken)
+ .GetTvShowRecommendationsAsync(tmdbId, language, page, cancellationToken)
.ConfigureAwait(false);
if (searchResults?.Results is null || searchResults.Results.Count == 0)
@@ -753,6 +756,84 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
return _tmDbClient.Config;
}
+ /// <summary>
+ /// Stores a response under the shared expiry, weighed by what it costs to keep.
+ /// </summary>
+ private void Cache<T>(string key, T value)
+ where T : class
+ => _memoryCache.Set(
+ key,
+ value,
+ new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheDurationInHours),
+ Size = EstimateSize(value)
+ });
+
+ /// <summary>
+ /// Stores a page of search results, whose weight is simply how many there are.
+ /// </summary>
+ private void CacheSearch<T>(string key, SearchContainer<T> results)
+ => _memoryCache.Set(
+ key,
+ results,
+ new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheDurationInHours),
+ Size = 1 + Count(results.Results)
+ });
+
+ private static long Count<T>(IReadOnlyCollection<T>? items) => items?.Count ?? 0;
+
+ /// <summary>
+ /// Estimates what keeping a response costs, counting the sub-records that dominate it.
+ /// </summary>
+ private static long EstimateSize(object? value) => value switch
+ {
+ TvShow series => 1
+ + Count(series.Credits?.Cast) + Count(series.Credits?.Crew)
+ + EstimateAggregateSize(series.AggregateCredits)
+ + Count(series.Seasons),
+ TvSeason season => 1
+ + Count(season.Credits?.Cast) + Count(season.Credits?.Crew)
+ + Count(season.Episodes),
+ TvEpisode episode => 1
+ + Count(episode.Credits?.Cast) + Count(episode.Credits?.Crew)
+ + Count(episode.Credits?.GuestStars),
+ Movie movie => 1 + Count(movie.Credits?.Cast) + Count(movie.Credits?.Crew),
+ Collection collection => 1 + Count(collection.Parts),
+ TvGroupCollection groups => 1 + Count(groups.Groups),
+ FindContainer found => 1
+ + Count(found.MovieResults) + Count(found.TvResults)
+ + Count(found.PersonResults) + Count(found.TvEpisode) + Count(found.TvSeason),
+ _ => 1
+ };
+
+ /// <summary>
+ /// Weighs aggregate credits, where each person carries one record per episode they worked on.
+ /// </summary>
+ private static long EstimateAggregateSize(CreditsAggregate? credits)
+ {
+ if (credits is null)
+ {
+ return 0;
+ }
+
+ var size = Count(credits.Cast) + Count(credits.Crew);
+
+ foreach (var cast in credits.Cast ?? [])
+ {
+ size += Count(cast.Roles);
+ }
+
+ foreach (var crew in credits.Crew ?? [])
+ {
+ size += Count(crew.Jobs);
+ }
+
+ return size;
+ }
+
/// <inheritdoc />
public void Dispose()
{
@@ -768,7 +849,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
{
if (disposing)
{
- _memoryCache?.Dispose();
+ _memoryCache.Dispose();
_tmDbClient?.Dispose();
}
}
diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
index c3458d4b2a..cd9dda21a0 100644
--- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
+++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
@@ -33,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles
private readonly ILibraryMonitor _monitor;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly ILocalizationManager _localization;
+ private readonly IDirectoryService _directoryService;
private readonly HashSet<string> _allowedSubtitleFormats;
private readonly ISubtitleProvider[] _subtitleProviders;
@@ -43,6 +44,7 @@ namespace MediaBrowser.Providers.Subtitles
ILibraryMonitor monitor,
IMediaSourceManager mediaSourceManager,
ILocalizationManager localizationManager,
+ IDirectoryService directoryService,
IEnumerable<ISubtitleProvider> subtitleProviders,
NamingOptions namingOptions)
{
@@ -51,6 +53,7 @@ namespace MediaBrowser.Providers.Subtitles
_monitor = monitor;
_mediaSourceManager = mediaSourceManager;
_localization = localizationManager;
+ _directoryService = directoryService;
_subtitleProviders = subtitleProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
@@ -281,6 +284,8 @@ namespace MediaBrowser.Providers.Subtitles
await stream.CopyToAsync(fs).ConfigureAwait(false);
}
+ _directoryService.Invalidate(path);
+
return;
}
else
@@ -395,6 +400,8 @@ namespace MediaBrowser.Providers.Subtitles
_monitor.ReportFileSystemChangeComplete(path, false);
}
+ _directoryService.Invalidate(path);
+
return item.RefreshMetadata(CancellationToken.None);
}
diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
index 596ca8d201..f662ac2367 100644
--- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
+++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
@@ -44,6 +44,31 @@ public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo>
{
var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
+ // An episode cannot end before it starts.
+ if (item.IndexNumberEnd < item.IndexNumber)
+ {
+ Logger.LogWarning(
+ "Discarding episode range end {IndexNumberEnd} preceding episode number {IndexNumber} for {Path}",
+ item.IndexNumberEnd,
+ item.IndexNumber,
+ item.Path);
+
+ item.IndexNumberEnd = null;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
+ else if (item.IndexNumberEnd.HasValue && !item.IndexNumber.HasValue)
+ {
+ // Without a first episode the end does not describe a range. Promoting it to the episode number
+ // would invent an identity the metadata never supplied, so drop the orphaned value instead.
+ Logger.LogWarning(
+ "Discarding episode range end {IndexNumberEnd} without an episode number for {Path}",
+ item.IndexNumberEnd,
+ item.Path);
+
+ item.IndexNumberEnd = null;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
+
var seriesName = item.FindSeriesName();
if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal))
{
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index 803fab538f..b350f482c3 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -364,7 +364,7 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
foreach (var episode in episodes)
{
var season = seasons.FirstOrDefault(i => i.IndexNumber == episode.ParentIndexNumber);
- if (season is null || (episode.SeasonId.Equals(season.Id) && episode.ParentId.Equals(season.Id)))
+ if (season is null || episode.SeasonId.Equals(season.Id))
{
continue;
}
@@ -372,11 +372,6 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
// Assign the correct season id and name to episode.
episode.SeasonId = season.Id;
episode.SeasonName = season.Name;
-
- // We need to set ParentId here for episodes in virtual seasons (e.g., flat structures), otherwise it retains the
- // ParentId from the series.
- episode.SetParent(season);
-
await episode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
}
diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
index 19b1bbe7b6..c3e5c791c0 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Text;
using System.Threading;
using System.Xml;
@@ -44,41 +46,60 @@ namespace MediaBrowser.XbmcMetadata.Parsers
var xmlFile = File.ReadAllText(metadataFile);
- var srch = "</episodedetails>";
- var index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase);
-
- var xml = xmlFile;
+ // Split the nfo into its episodedetails blocks.
+ // This is needed because XBMC metadata uses multiple episodedetails blocks instead of an episodenumberend tag.
+ const string Srch = "</episodedetails>";
+ var blocks = new List<string>();
+ int index;
+ while ((index = xmlFile.IndexOf(Srch, StringComparison.OrdinalIgnoreCase)) != -1)
+ {
+ blocks.Add(xmlFile.Substring(0, index + Srch.Length));
+ xmlFile = xmlFile.Substring(index + Srch.Length);
+ }
- if (index != -1)
+ if (blocks.Count == 0)
{
- xml = xmlFile.Substring(0, index + srch.Length);
- xmlFile = xmlFile.Substring(index + srch.Length);
+ // No closing tag, let the xml reader deal with whatever is in the file
+ blocks.Add(xmlFile);
}
// These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions
try
{
- // Extract episode details from the first episodedetails block
- ReadEpisodeDetailsFromXml(item, xml, settings, cancellationToken);
+ if (blocks.Count == 1)
+ {
+ ReadEpisodeDetailsFromXml(item, blocks[0], settings, cancellationToken);
+ return;
+ }
+
+ // The blocks are not guaranteed to be written in ascending episode order, so parse them all
+ // and sort them before merging.
+ var episodes = blocks
+ .Select(block =>
+ {
+ var episode = new MetadataResult<Episode>()
+ {
+ Item = new Episode()
+ };
- // Extract the last episode number from nfo
- // Retrieves all additional episodedetails blocks from the rest of the nfo and concatenates the name, originalTitle and overview tags with the first episode
- // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag
+ ReadEpisodeDetailsFromXml(episode, block, settings, cancellationToken);
+
+ return (Xml: block, Result: episode);
+ })
+ .OrderBy(episode => episode.Result.Item.IndexNumber ?? int.MaxValue)
+ .ToList();
+
+ // Extract the details of the lowest numbered episode into the item that is returned to the caller
+ ReadEpisodeDetailsFromXml(item, episodes[0].Xml, settings, cancellationToken);
+
+ // Concatenate the name, originalTitle and overview tags of the remaining episodes with the first one
+ // and take the highest episode number as the last episode of the file
var name = new StringBuilder(item.Item.Name);
var originalTitle = new StringBuilder(item.Item.OriginalTitle);
var overview = new StringBuilder(item.Item.Overview);
- while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1)
+ for (var i = 1; i < episodes.Count; i++)
{
- xml = xmlFile.Substring(0, index + srch.Length);
- xmlFile = xmlFile.Substring(index + srch.Length);
-
- var additionalEpisode = new MetadataResult<Episode>()
- {
- Item = new Episode()
- };
-
- // Extract episode details from additional episodedetails block
- ReadEpisodeDetailsFromXml(additionalEpisode, xml, settings, cancellationToken);
+ var additionalEpisode = episodes[i].Result;
if (!string.IsNullOrEmpty(additionalEpisode.Item.Name))
{
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
index 27dbeaba6a..77abb45f2a 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
@@ -37,14 +37,16 @@ public interface IJellyfinDatabaseProvider
void ConfigureConventions(ModelConfigurationBuilder configurationBuilder);
/// <summary>
- /// If supported this should run any periodic maintaince tasks.
+ /// If supported this should run any periodic maintaince tasks, reclaiming unused space and refreshing the query
+ /// planner statistics. Also used after migrations have modified the database.
/// </summary>
/// <param name="cancellationToken">The token to abort the operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RunScheduledOptimisation(CancellationToken cancellationToken);
/// <summary>
- /// If supported this should perform any actions that are required on stopping the jellyfin server.
+ /// If supported this should perform any actions that are required on stopping the jellyfin server, including the
+ /// same maintenance as <see cref="RunScheduledOptimisation(CancellationToken)"/>.
/// </summary>
/// <param name="cancellationToken">The token that will be used to abort the operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
index 8020fe1f93..f11cde7e48 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
@@ -103,17 +103,9 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
/// <inheritdoc/>
- public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
+ public Task RunScheduledOptimisation(CancellationToken cancellationToken)
{
- var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
- await using (context.ConfigureAwait(false))
- {
- await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
- await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
- await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
- await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
- _logger.LogInformation("jellyfin.db optimized successfully!");
- }
+ return OptimizeAsync(cancellationToken);
}
/// <inheritdoc/>
@@ -125,19 +117,37 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
/// <inheritdoc/>
public async Task RunShutdownTask(CancellationToken cancellationToken)
{
+ // Run before disposing the application
+ try
+ {
+ await OptimizeAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // A missed optimization only costs performance, so never fail the shutdown over this.
+ _logger.LogError(ex, "Error while optimizing jellyfin.db");
+ }
+
+ SqliteConnection.ClearAllPools();
+ }
+
+ private async Task OptimizeAsync(CancellationToken cancellationToken)
+ {
if (DbContextFactory is null)
{
return;
}
- // Run before disposing the application
var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
- await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
+ await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
+ await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
+ await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false);
+ await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
+ await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("jellyfin.db optimized successfully!");
}
-
- SqliteConnection.ClearAllPools();
}
/// <inheritdoc/>
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index b6d2914efa..3e353db8de 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Model.Drawing;
using Microsoft.Extensions.Logging;
using SkiaSharp;
+using Svg;
using Svg.Skia;
namespace Jellyfin.Drawing.Skia;
@@ -48,6 +49,13 @@ public class SkiaEncoder : IImageEncoder
/// </summary>
public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
+ static SkiaEncoder()
+ {
+ SvgDocument.ResolveExternalElements = ExternalType.None;
+ SvgDocument.ResolveExternalImages = ExternalType.None;
+ SvgDocument.ResolveExternalXmlEntites = ExternalType.None;
+ }
+
/// <summary>
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
/// </summary>
@@ -183,6 +191,12 @@ public class SkiaEncoder : IImageEncoder
var extension = Path.GetExtension(path.AsSpan());
if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
{
+ if (!SvgSecurityValidator.IsSafe(path, out var reason))
+ {
+ _logger.LogError("Refusing to determine dimensions for SVG {FilePath}: {Reason}", path, reason);
+ return default;
+ }
+
using var svg = new SKSvg();
try
{
@@ -445,6 +459,12 @@ public class SkiaEncoder : IImageEncoder
throw new FileNotFoundException("File not found", path);
}
+ if (!SvgSecurityValidator.IsSafe(path, out var reason))
+ {
+ _logger.LogError("Refusing to render SVG {FilePath}: {Reason}", path, reason);
+ return null;
+ }
+
using var svg = SKSvg.CreateFromFile(path);
if (svg.Drawable is null)
{
diff --git a/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs b/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs
new file mode 100644
index 0000000000..65f35643b2
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs
@@ -0,0 +1,339 @@
+using System;
+using System.Buffers;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.IO.Compression;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Xml;
+
+[assembly: InternalsVisibleTo("Jellyfin.Drawing.Skia.Tests")]
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Validates that an SVG document does not reference external resources before it is rasterized.
+/// </summary>
+internal static class SvgSecurityValidator
+{
+ // Guards against a chain of nested data:image/svg+xml payloads.
+ private const int MaxDataUriDepth = 4;
+
+ // Upper bound for a decompressed svgz payload carried inside a data URI, to guard against decompression bombs.
+ private const int MaxDecompressedBytes = 16 * 1024 * 1024;
+
+ private const int DecompressBufferSize = 81920;
+
+ private static readonly XmlReaderSettings _scanSettings = new()
+ {
+ DtdProcessing = DtdProcessing.Parse,
+ XmlResolver = null,
+ MaxCharactersFromEntities = 1024 * 1024,
+ IgnoreComments = true,
+ IgnoreProcessingInstructions = true,
+ IgnoreWhitespace = true,
+ CloseInput = false
+ };
+
+ /// <summary>
+ /// Determines whether the SVG at the given path is safe to rasterize, i.e. contains no references
+ /// to external resources.
+ /// </summary>
+ /// <param name="path">The path to the SVG file.</param>
+ /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
+ /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
+ public static bool IsSafe(string path, [NotNullWhen(false)] out string? reason)
+ {
+ try
+ {
+ using var stream = File.OpenRead(path);
+ reason = Validate(stream, 0);
+ }
+ catch (IOException ex)
+ {
+ reason = "Unable to read the file for validation: " + ex.Message;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ reason = "Unable to read the file for validation: " + ex.Message;
+ }
+
+ return reason is null;
+ }
+
+ /// <summary>
+ /// Determines whether the SVG in the given stream is safe to rasterize.
+ /// </summary>
+ /// <param name="stream">The stream containing the SVG document.</param>
+ /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
+ /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
+ public static bool IsSafe(Stream stream, [NotNullWhen(false)] out string? reason)
+ {
+ reason = Validate(stream, 0);
+ return reason is null;
+ }
+
+ private static string? Validate(Stream stream, int depth)
+ {
+ try
+ {
+ using var reader = XmlReader.Create(stream, _scanSettings);
+ while (reader.Read())
+ {
+ switch (reader.NodeType)
+ {
+ case XmlNodeType.DocumentType:
+ {
+ var subset = reader.Value;
+ if (!string.IsNullOrEmpty(subset)
+ && (subset.Contains("SYSTEM", StringComparison.OrdinalIgnoreCase)
+ || subset.Contains("PUBLIC", StringComparison.OrdinalIgnoreCase)))
+ {
+ return "The document declares an external DTD entity";
+ }
+
+ break;
+ }
+
+ case XmlNodeType.Element when reader.HasAttributes:
+ {
+ for (var i = 0; i < reader.AttributeCount; i++)
+ {
+ reader.MoveToAttribute(i);
+ var isHref = reader.LocalName.Equals("href", StringComparison.OrdinalIgnoreCase);
+ var reason = isHref
+ ? ValidateReference(reader.Value, depth, "href")
+ : ValidateCss(reader.Value, depth);
+ if (reason is not null)
+ {
+ return reason;
+ }
+ }
+
+ reader.MoveToElement();
+ break;
+ }
+
+ case XmlNodeType.Text:
+ case XmlNodeType.CDATA:
+ {
+ var reason = ValidateCss(reader.Value, depth);
+ if (reason is not null)
+ {
+ return reason;
+ }
+
+ break;
+ }
+ }
+ }
+
+ return null;
+ }
+ catch (XmlException ex)
+ {
+ // Malformed markup, a forbidden DTD construct or an unresolved external entity: refuse to render.
+ return "The document could not be safely parsed: " + ex.Message;
+ }
+ }
+
+ private static string? ValidateReference(ReadOnlySpan<char> value, int depth, string context)
+ {
+ var trimmed = value.Trim();
+ if (trimmed.IsEmpty || trimmed[0] == '#')
+ {
+ return null;
+ }
+
+ if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ {
+ return ValidateDataUri(trimmed, depth, context);
+ }
+
+ return "An external resource is referenced via " + context;
+ }
+
+ private static string? ValidateDataUri(ReadOnlySpan<char> dataUri, int depth, string context)
+ {
+ // "data:[<mediatype>][;base64],<payload>" (mirrors Svg.Model's data URI parsing).
+ var comma = dataUri.IndexOf(',');
+ if (comma < 0)
+ {
+ return "A malformed data URI is referenced via " + context;
+ }
+
+ var header = dataUri[5..comma];
+ var firstSeparator = header.IndexOf(';');
+ var mediaType = (firstSeparator < 0 ? header : header[..firstSeparator]).Trim();
+
+ // Only "image/svg+xml" is re-parsed as SVG by the renderer; any other type is treated as raster data.
+ if (!mediaType.Contains('/') || !mediaType.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ if (depth >= MaxDataUriDepth)
+ {
+ return "Nested data URIs exceed the allowed depth";
+ }
+
+ var lastSeparator = header.LastIndexOf(';');
+ var isBase64 = lastSeparator >= 0
+ && header[(lastSeparator + 1)..].Trim().Equals("base64", StringComparison.OrdinalIgnoreCase);
+
+ var payload = dataUri[(comma + 1)..].Trim();
+ byte[]? buffer = null;
+ try
+ {
+ int length;
+ if (isBase64)
+ {
+ buffer = ArrayPool<byte>.Shared.Rent((payload.Length / 4 * 3) + 3);
+ if (!Convert.TryFromBase64Chars(payload, buffer, out length))
+ {
+ return "An undecodable data URI is referenced via " + context;
+ }
+ }
+ else
+ {
+ var unescaped = Uri.UnescapeDataString(payload.ToString());
+ buffer = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(unescaped.Length));
+ length = Encoding.UTF8.GetBytes(unescaped, buffer);
+ }
+
+ if (length > 2 && buffer[0] == 0x1F && buffer[1] == 0x8B)
+ {
+ using var decompressed = Decompress(buffer, length);
+ return Validate(decompressed, depth + 1);
+ }
+
+ using var stream = new MemoryStream(buffer, 0, length, false);
+ return Validate(stream, depth + 1);
+ }
+ catch (FormatException ex)
+ {
+ return "An undecodable data URI is referenced via " + context + ": " + ex.Message;
+ }
+ catch (InvalidDataException ex)
+ {
+ return "An invalid compressed data URI is referenced via " + context + ": " + ex.Message;
+ }
+ finally
+ {
+ if (buffer is not null)
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+ }
+ }
+
+ private static MemoryStream Decompress(byte[] compressed, int length)
+ {
+ using var input = new MemoryStream(compressed, 0, length, false);
+ using var gzip = new GZipStream(input, CompressionMode.Decompress);
+ var output = new MemoryStream();
+ var buffer = ArrayPool<byte>.Shared.Rent(DecompressBufferSize);
+ try
+ {
+ var total = 0;
+ int read;
+ while ((read = gzip.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ total += read;
+ if (total > MaxDecompressedBytes)
+ {
+ throw new InvalidDataException("Compressed data URI exceeds the allowed size");
+ }
+
+ output.Write(buffer, 0, read);
+ }
+ }
+ catch
+ {
+ output.Dispose();
+ throw;
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+
+ output.Position = 0;
+ return output;
+ }
+
+ private static string? ValidateCss(ReadOnlySpan<char> value, int depth)
+ {
+ if (value.IsEmpty)
+ {
+ return null;
+ }
+
+ var index = 0;
+ while (true)
+ {
+ var found = value[index..].IndexOf("url(", StringComparison.OrdinalIgnoreCase);
+ if (found < 0)
+ {
+ break;
+ }
+
+ var start = index + found + 4;
+ var close = value[start..].IndexOf(')');
+ if (close < 0)
+ {
+ break;
+ }
+
+ var target = value.Slice(start, close).Trim();
+ target = target.Trim('\'');
+ target = target.Trim('"').Trim();
+ var reason = ValidateReference(target, depth, "url()");
+ if (reason is not null)
+ {
+ return reason;
+ }
+
+ index = start + close + 1;
+ if (index >= value.Length)
+ {
+ break;
+ }
+ }
+
+ // Handle the bare "@import '...';" form (the "@import url(...)" form is covered above).
+ index = 0;
+ while (true)
+ {
+ var found = value[index..].IndexOf("@import", StringComparison.OrdinalIgnoreCase);
+ if (found < 0)
+ {
+ break;
+ }
+
+ var rest = value[(index + found + 7)..];
+ var quote = rest.IndexOfAny('\'', '"');
+ if (quote >= 0)
+ {
+ var afterQuote = rest[(quote + 1)..];
+ var end = afterQuote.IndexOfAny('\'', '"');
+ if (end >= 0)
+ {
+ var reason = ValidateReference(afterQuote[..end], depth, "@import");
+ if (reason is not null)
+ {
+ return reason;
+ }
+ }
+ }
+
+ index = index + found + 7;
+ if (index >= value.Length)
+ {
+ break;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
index fb606be0ef..902ca76af8 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
@@ -32,6 +32,7 @@ namespace Jellyfin.LiveTv.TunerHosts
{
private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"];
private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"];
+ private static readonly string[] _manifestExtensions = [".m3u8", ".m3u", ".mpd"];
private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerApplicationHost _appHost;
@@ -151,11 +152,20 @@ namespace Jellyfin.LiveTv.TunerHosts
var protocol = _mediaSourceManager.GetPathProtocol(path);
var isRemote = true;
- if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
+ Uri.TryCreate(path, UriKind.Absolute, out var uri);
+ if (uri is not null)
{
isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
}
+ // A manifest is not a byte stream. Serving one directly hands the client a playlist whose
+ // variant and segment URIs are relative to the origin, and those do not resolve against the
+ // Jellyfin url the client fetched it from. Remux or transcode these instead.
+ if (IsManifest(path, uri))
+ {
+ supportsDirectPlay = false;
+ }
+
var httpHeaders = new Dictionary<string, string>();
if (protocol == MediaProtocol.Http)
@@ -210,6 +220,20 @@ namespace Jellyfin.LiveTv.TunerHosts
return mediaSource;
}
+ /// <summary>
+ /// Determines whether a channel path points at an HLS or DASH manifest rather than at a byte stream.
+ /// </summary>
+ /// <param name="path">The channel path.</param>
+ /// <param name="uri">The channel path parsed as an absolute uri, or <c>null</c> if it is not one.</param>
+ /// <returns><c>true</c> if the path names a streaming manifest.</returns>
+ private static bool IsManifest(string path, Uri uri)
+ {
+ // Use the uri path when there is one so that a query string does not hide the extension.
+ var extension = Path.GetExtension(uri is null ? path : uri.AbsolutePath);
+
+ return _manifestExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
+ }
+
public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
{
return Task.FromResult(new List<TunerHostInfo>());
diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
index 1f59908a86..e57fbfe473 100644
--- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
+++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+using System.IO;
using System.Linq;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
@@ -8,29 +10,31 @@ namespace Jellyfin.Controller.Tests
{
public class DirectoryServiceTests
{
- private const string LowerCasePath = "/music/someartist";
- private const string UpperCasePath = "/music/SOMEARTIST";
+ // Path.GetDirectoryName, which Invalidate uses to find the parent, normalizes the
+ // separators, so cache keys only match the parent it returns when they use the platform's.
+ private static readonly string _lowerCasePath = LocalPath("/music/someartist");
+ private static readonly string _upperCasePath = LocalPath("/music/SOMEARTIST");
private static readonly FileSystemMetadata[] _lowerCaseFileSystemMetadata =
{
new()
{
- FullName = LowerCasePath + "/Artwork",
+ FullName = Path.Combine(_lowerCasePath, "Artwork"),
IsDirectory = true
},
new()
{
- FullName = LowerCasePath + "/Some Other Folder",
+ FullName = Path.Combine(_lowerCasePath, "Some Other Folder"),
IsDirectory = true
},
new()
{
- FullName = LowerCasePath + "/Song 2.mp3",
+ FullName = Path.Combine(_lowerCasePath, "Song 2.mp3"),
IsDirectory = false
},
new()
{
- FullName = LowerCasePath + "/Song 3.mp3",
+ FullName = Path.Combine(_lowerCasePath, "Song 3.mp3"),
IsDirectory = false
}
};
@@ -39,12 +43,12 @@ namespace Jellyfin.Controller.Tests
{
new()
{
- FullName = UpperCasePath + "/Lyrics",
+ FullName = Path.Combine(_upperCasePath, "Lyrics"),
IsDirectory = true
},
new()
{
- FullName = UpperCasePath + "/Song 1.mp3",
+ FullName = Path.Combine(_upperCasePath, "Song 1.mp3"),
IsDirectory = false
}
};
@@ -53,12 +57,12 @@ namespace Jellyfin.Controller.Tests
public void GetFileSystemEntries_GivenPathsWithDifferentCasing_CachesAll()
{
var fileSystemMock = new Mock<IFileSystem>();
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
- var upperCaseResult = directoryService.GetFileSystemEntries(UpperCasePath);
- var lowerCaseResult = directoryService.GetFileSystemEntries(LowerCasePath);
+ var upperCaseResult = directoryService.GetFileSystemEntries(_upperCasePath);
+ var lowerCaseResult = directoryService.GetFileSystemEntries(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata, upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata, lowerCaseResult);
@@ -68,12 +72,12 @@ namespace Jellyfin.Controller.Tests
public void GetFiles_GivenPathsWithDifferentCasing_ReturnsCorrectFiles()
{
var fileSystemMock = new Mock<IFileSystem>();
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
- var upperCaseResult = directoryService.GetFiles(UpperCasePath);
- var lowerCaseResult = directoryService.GetFiles(LowerCasePath);
+ var upperCaseResult = directoryService.GetFiles(_upperCasePath);
+ var lowerCaseResult = directoryService.GetFiles(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata.Where(f => !f.IsDirectory), upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => !f.IsDirectory), lowerCaseResult);
@@ -83,12 +87,12 @@ namespace Jellyfin.Controller.Tests
public void GetDirectories_GivenPathsWithDifferentCasing_ReturnsCorrectDirectories()
{
var fileSystemMock = new Mock<IFileSystem>();
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
- var upperCaseResult = directoryService.GetDirectories(UpperCasePath);
- var lowerCaseResult = directoryService.GetDirectories(LowerCasePath);
+ var upperCaseResult = directoryService.GetDirectories(_upperCasePath);
+ var lowerCaseResult = directoryService.GetDirectories(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata.Where(f => f.IsDirectory), upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => f.IsDirectory), lowerCaseResult);
@@ -248,5 +252,171 @@ namespace Jellyfin.Controller.Tests
Assert.Equal(cachedPaths, result);
Assert.Equal(newPaths, secondResult);
}
+
+ [Fact]
+ public void GetFileSystemEntries_RepeatedPath_ReadsTheFileSystemOnce()
+ {
+ var fileSystemMock = new Mock<IFileSystem>(MockBehavior.Strict);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(_lowerCasePath))
+ .Returns(_lowerCaseFileSystemMetadata);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ directoryService.GetFileSystemEntries(_lowerCasePath);
+ directoryService.GetFileSystemEntries(_lowerCasePath);
+
+ fileSystemMock.Verify(f => f.GetFileSystemEntries(_lowerCasePath), Times.Once);
+ }
+
+ [Fact]
+ public void Invalidate_GivenADirectory_DropsBothTheListingAndTheFilePaths()
+ {
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath))
+ .Returns(_lowerCaseFileSystemMetadata)
+ .Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.SetupSequence(f => f.GetFilePaths(_lowerCasePath, false))
+ .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") })
+ .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3"), Path.Combine(_lowerCasePath, "Song 2.srt") });
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+ directoryService.GetFileSystemEntries(_lowerCasePath);
+ directoryService.GetFilePaths(_lowerCasePath);
+
+ directoryService.Invalidate(_lowerCasePath);
+
+ Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath));
+ Assert.Equal(2, directoryService.GetFilePaths(_lowerCasePath).Count);
+ }
+
+ [Fact]
+ public void Invalidate_GivenAFile_DropsTheListingOfTheDirectoryHoldingIt()
+ {
+ var newFile = Path.Combine(_lowerCasePath, "Song 2.srt");
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath))
+ .Returns(_lowerCaseFileSystemMetadata)
+ .Returns(_upperCaseFileSystemMetadata);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+ directoryService.GetFileSystemEntries(_lowerCasePath);
+
+ directoryService.Invalidate(newFile);
+
+ Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath));
+ }
+
+ [Fact]
+ public void GetFilePaths_ClearingTheCache_KeepsTheParentDirectory()
+ {
+ var parentPath = LocalPath("/music");
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.Setup(f => f.GetFilePaths(_lowerCasePath))
+ .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") });
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(parentPath))
+ .Returns(_lowerCaseFileSystemMetadata);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+ directoryService.GetFileSystemEntries(parentPath);
+
+ directoryService.GetFilePaths(_lowerCasePath, true);
+
+ directoryService.GetFileSystemEntries(parentPath);
+ fileSystemMock.Verify(f => f.GetFileSystemEntries(parentPath), Times.Once);
+ }
+
+ [Fact]
+ public void GetFileSystemEntries_MoreRecordsThanTheCeiling_DropsCache()
+ {
+ // Charged by the files in a listing, not the number of listings, so a few big folders
+ // reach the limit where a lot of small ones would not.
+ const int FolderCount = 60;
+ var bigListing = new FileSystemMetadata[5000];
+ for (var i = 0; i < bigListing.Length; i++)
+ {
+ bigListing[i] = new FileSystemMetadata
+ {
+ FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture),
+ IsDirectory = false
+ };
+ }
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>()))
+ .Returns(bigListing);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ const string FirstPath = "/music/artist0";
+ directoryService.GetFileSystemEntries(FirstPath);
+
+ for (var i = 1; i < FolderCount; i++)
+ {
+ directoryService.GetFileSystemEntries("/music/artist" + i.ToString(CultureInfo.InvariantCulture));
+ }
+
+ directoryService.GetFileSystemEntries(FirstPath);
+
+ fileSystemMock.Verify(f => f.GetFileSystemEntries(FirstPath), Times.Exactly(2));
+ }
+
+ [Fact]
+ public void GetFileSystemEntries_RepeatedlyInvalidatedFolder_KeepsUnrelatedEntriesCached()
+ {
+ // Invalidating gives the records back, so churning one folder must not add up to the
+ // ceiling and drop everything else with it.
+ const int ChurnCount = 50;
+ var bigListing = new FileSystemMetadata[5000];
+ for (var i = 0; i < bigListing.Length; i++)
+ {
+ bigListing[i] = new FileSystemMetadata
+ {
+ FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture),
+ IsDirectory = false
+ };
+ }
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>()))
+ .Returns(bigListing);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ const string ChurnedPath = "/music/watched";
+ const string StablePath = "/music/untouched";
+ directoryService.GetFileSystemEntries(StablePath);
+
+ for (var i = 0; i < ChurnCount; i++)
+ {
+ directoryService.GetFileSystemEntries(ChurnedPath);
+ directoryService.Invalidate(ChurnedPath);
+ }
+
+ directoryService.GetFileSystemEntries(StablePath);
+
+ fileSystemMock.Verify(f => f.GetFileSystemEntries(StablePath), Times.Once);
+ }
+
+ [Fact]
+ public void GetFileSystemEntry_MissingPath_IsNotRemembered()
+ {
+ const string MissingPath = "/music/not-here";
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.SetupSequence(f => f.GetFileSystemInfo(MissingPath))
+ .Returns(new FileSystemMetadata { FullName = MissingPath, Exists = false })
+ .Returns(new FileSystemMetadata { FullName = MissingPath, Exists = true });
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ Assert.Null(directoryService.GetFileSystemEntry(MissingPath));
+
+ Assert.NotNull(directoryService.GetFileSystemEntry(MissingPath));
+ }
+
+ private static string LocalPath(string path)
+ => path.Replace('/', Path.DirectorySeparatorChar);
}
}
diff --git a/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs b/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs
new file mode 100644
index 0000000000..b4ec2f1903
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs
@@ -0,0 +1,78 @@
+using System;
+using System.IO;
+using MediaBrowser.Controller.IO;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.IO;
+
+public class FileSystemHelperTests
+{
+ private static readonly string _parentPath = Path.Combine(Path.GetTempPath(), "jellyfin-test", "root", "default");
+
+ [Theory]
+ [InlineData("Movies")]
+ [InlineData("My Movies")]
+ [InlineData("..2")]
+ [InlineData("a.b")]
+ public void GetChildPath_ValidName_ReturnsPathInsideParent(string name)
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath, name);
+
+ Assert.Equal(Path.Combine(_parentPath, name), path);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(".")]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData("../../etc")]
+ [InlineData("Movies/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ [InlineData("sub/folder")]
+ [InlineData("with\0null")]
+ public void GetChildPath_EscapingName_ReturnsNull(string name)
+ {
+ Assert.Null(FileSystemHelper.GetChildPath(_parentPath, name));
+ }
+
+ [Theory]
+ [InlineData("..\\..")]
+ [InlineData("sub\\folder")]
+ [InlineData("C:\\Windows")]
+ public void GetChildPath_WindowsSeparator_DoesNotEscapeParent(string name)
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath, name);
+
+ // On Windows these are rejected outright, on other platforms a backslash is a legal file name character.
+ Assert.True(path is null || string.Equals(Path.GetDirectoryName(path), _parentPath, StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData("...")]
+ [InlineData("Movies.")]
+ [InlineData("Movies ")]
+ public void GetChildPath_TrailingDotOrSpace_RejectedOnWindows(string name)
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath, name);
+
+ if (OperatingSystem.IsWindows())
+ {
+ // Windows trims trailing dots and spaces, so the name would resolve to the parent or to a different child.
+ Assert.Null(path);
+ }
+ else
+ {
+ Assert.Equal(Path.Combine(_parentPath, name), path);
+ }
+ }
+
+ [Fact]
+ public void GetChildPath_ParentWithTrailingSeparator_ReturnsPathInsideParent()
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath + Path.DirectorySeparatorChar, "Movies");
+
+ Assert.Equal(Path.Combine(_parentPath, "Movies"), path);
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs
new file mode 100644
index 0000000000..686d839f4f
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.LibraryTaskScheduler;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.LibraryTaskScheduler
+{
+ public class LimitedConcurrencyLibrarySchedulerTests
+ {
+ private static readonly TimeSpan _shortGracePeriod = TimeSpan.FromMilliseconds(50);
+
+ // Generous, because these only ever wait for something that should already have happened.
+ private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
+
+ [Fact]
+ public async Task Enqueue_ProcessesEveryItem()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping);
+ await using (scheduler)
+ {
+ var data = Enumerable.Range(0, 100).ToArray();
+ var processed = new ConcurrentBag<int>();
+
+ await scheduler.Enqueue(
+ data,
+ (item, _) =>
+ {
+ processed.Add(item);
+ return Task.CompletedTask;
+ },
+ new Progress<double>(),
+ CancellationToken.None);
+
+ Assert.Equal(data, processed.Order());
+ }
+ }
+
+ [Fact]
+ public async Task Enqueue_WithFailingWorker_StillCompletes()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping);
+ await using (scheduler)
+ {
+ await scheduler.Enqueue(
+ Enumerable.Range(0, 20).ToArray(),
+ (item, _) => item % 2 == 0 ? throw new InvalidOperationException("boom") : Task.CompletedTask,
+ new Progress<double>(),
+ CancellationToken.None);
+ }
+ }
+
+ /// <summary>
+ /// The runners wait on a source linked to <see cref="IHostApplicationLifetime.ApplicationStopping"/>,
+ /// so a shutdown has to reach them. It does not travel from the linked source back to the one
+ /// the cleanup cancels, which is what made them immortal.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
+ [Fact]
+ public async Task ApplicationStopping_RetiresRunners()
+ {
+ using var appStopping = new CancellationTokenSource();
+
+ // Long enough that the cleanup cannot be what retires them.
+ var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
+ await using (scheduler)
+ {
+ await RunOneOperation(scheduler);
+ Assert.True(scheduler.ActiveRunnerCount > 0);
+
+ await appStopping.CancelAsync();
+
+ await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
+ }
+ }
+
+ /// <summary>
+ /// The cleanup used to be a one shot: it never released the scheduling slot it took, so
+ /// every runner spawned after the first pass stayed around for the lifetime of the server.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
+ [Fact]
+ public async Task Enqueue_RetiresIdleRunnersAfterEveryOperation()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping);
+ await using (scheduler)
+ {
+ for (var round = 0; round < 3; round++)
+ {
+ await RunOneOperation(scheduler);
+ Assert.True(scheduler.ActiveRunnerCount > 0, $"no runner spawned in round {round}");
+
+ await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Disposing used to sit out the rest of the cleanup grace period, holding up shutdown for
+ /// up to a minute.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
+ [Fact]
+ public async Task DisposeAsync_DoesNotWaitOutTheGracePeriod()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
+
+ await RunOneOperation(scheduler);
+
+ var stopwatch = Stopwatch.StartNew();
+ await scheduler.DisposeAsync();
+
+ Assert.True(stopwatch.Elapsed < _timeout, $"disposing took {stopwatch.Elapsed}");
+ }
+
+ [Fact]
+ public async Task Enqueue_AfterDispose_DoesNothing()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping);
+ await scheduler.DisposeAsync();
+
+ var processed = 0;
+ await scheduler.Enqueue(
+ Enumerable.Range(0, 10).ToArray(),
+ (_, _) =>
+ {
+ Interlocked.Increment(ref processed);
+ return Task.CompletedTask;
+ },
+ new Progress<double>(),
+ CancellationToken.None);
+
+ Assert.Equal(0, processed);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(4)]
+ public async Task Enqueue_FromWithinAWorker_DoesNotDeadlock(int fanout)
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping, fanout: fanout);
+ await using (scheduler)
+ {
+ var inner = 0;
+
+ var outer = scheduler.Enqueue(
+ Enumerable.Range(0, 8).ToArray(),
+ (_, _) => scheduler.Enqueue(
+ Enumerable.Range(0, 4).ToArray(),
+ (_, _) =>
+ {
+ Interlocked.Increment(ref inner);
+ return Task.CompletedTask;
+ },
+ new Progress<double>(),
+ CancellationToken.None),
+ new Progress<double>(),
+ CancellationToken.None);
+
+ await outer.WaitAsync(_timeout, TestContext.Current.CancellationToken);
+
+ Assert.Equal(32, inner);
+ }
+ }
+
+ private static LimitedConcurrencyLibraryScheduler CreateScheduler(
+ CancellationTokenSource appStopping,
+ int fanout = 4,
+ TimeSpan? gracePeriod = null)
+ {
+ var lifetime = new Mock<IHostApplicationLifetime>();
+ lifetime.SetupGet(x => x.ApplicationStopping).Returns(() => appStopping.Token);
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.SetupGet(x => x.Configuration)
+ .Returns(new ServerConfiguration { LibraryScanFanoutConcurrency = fanout });
+
+ return new LimitedConcurrencyLibraryScheduler(
+ lifetime.Object,
+ NullLogger<LimitedConcurrencyLibraryScheduler>.Instance,
+ configurationManager.Object,
+ gracePeriod ?? _shortGracePeriod);
+ }
+
+ private static Task RunOneOperation(LimitedConcurrencyLibraryScheduler scheduler)
+ => scheduler.Enqueue(
+ Enumerable.Range(0, 8).ToArray(),
+ (_, _) => Task.CompletedTask,
+ new Progress<double>(),
+ CancellationToken.None);
+
+ private static async Task WaitForAsync(Func<bool> condition)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ while (!condition())
+ {
+ Assert.True(stopwatch.Elapsed < _timeout, "timed out waiting for the scheduler to settle");
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs
new file mode 100644
index 0000000000..557035e2d1
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs
@@ -0,0 +1,162 @@
+using System;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Streaming;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using Moq;
+using Xunit;
+
+using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding;
+
+public class EncodingHelperDoviTests
+{
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("bt709", false)]
+ [InlineData("unknown", false)]
+ [InlineData("bt2020-10", false)]
+ [InlineData("smpte2084", true)]
+ [InlineData("arib-std-b67", true)]
+ public void GetSwVidFilterChain_InvalidDovi_OnlyTonemapsHdrBaseLayer(string? transfer, bool tonemap)
+ {
+ var state = CreateState("hevc", transfer);
+ var helper = CreateHelper(true);
+
+ var (filters, _, _) = helper.GetSwVidFilterChain(state, new EncodingOptions(), "libx264");
+ var args = string.Join(',', filters);
+
+ Assert.Equal(VideoRangeType.DOVIInvalid, state.VideoStream.VideoRangeType);
+ Assert.Equal(tonemap, args.Contains("tonemapx=", StringComparison.Ordinal));
+ Assert.Contains(tonemap ? "color_trc=" + transfer : "color_trc=bt709", args, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("bt709", false)]
+ [InlineData("arib-std-b67", false)]
+ [InlineData("smpte2084", true)]
+ [InlineData("SMPTE2084", true)]
+ public void IsDoviWithHdr10Bl_InvalidDovi_RequiresPq(string? transfer, bool expected)
+ {
+ var stream = CreateState("hevc", transfer).VideoStream;
+
+ Assert.True(EncodingHelper.IsDovi(stream));
+ Assert.Equal(expected, EncodingHelper.IsDoviWithHdr10Bl(stream));
+ }
+
+ [Theory]
+ [InlineData("hevc", null, "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "bt709", "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "smpte2084", "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "arib-std-b67", "hevc_metadata=remove_dovi=1")]
+ [InlineData("av1", null, "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "bt709", "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "smpte2084", "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "arib-std-b67", "av1_metadata=remove_dovi=1")]
+ public void GetBitStreamArgs_InvalidDovi_PreservesClientDependentRemoval(string codec, string? transfer, string expected)
+ {
+ var state = CreateState(codec, transfer);
+ var helper = CreateHelper(true);
+
+ foreach (var (requestedRanges, removeDovi) in new[] { (null, false), ("SDR", false), ("HDR10", false), ("DOVIWithEL", false), ("DOVI", true), ("SDR,DOVI", true) })
+ {
+ state.BaseRequest.VideoRangeType = requestedRanges;
+
+ Assert.Equal(removeDovi, helper.IsDoviRemoved(state));
+ if (removeDovi)
+ {
+ Assert.Contains(expected, helper.GetBitStreamArgs(state, MediaStreamType.Video), StringComparison.Ordinal);
+ }
+ else
+ {
+ Assert.Equal(codec == "hevc" ? "-bsf:v hevc_mp4toannexb" : null, helper.GetBitStreamArgs(state, MediaStreamType.Video));
+ }
+
+ Assert.False(CreateHelper(false).IsDoviRemoved(state));
+ }
+ }
+
+ [Theory]
+ [InlineData(null, true)]
+ [InlineData("HDR10", true)]
+ [InlineData("DOVI", false)]
+ [InlineData("SDR,DOVI", false)]
+ public void CanStreamCopyVideo_InvalidDovi_RequiresRemovalSupportOnlyForDoviClients(string? requestedRanges, bool copyWithoutRemovalSupport)
+ {
+ foreach (var codec in new[] { "hevc", "av1" })
+ {
+ foreach (var transfer in new[] { "bt709", "smpte2084" })
+ {
+ var state = CreateState(codec, transfer);
+ state.BaseRequest.VideoRangeType = requestedRanges;
+
+ Assert.True(CreateHelper(true).CanStreamCopyVideo(state, state.VideoStream));
+ Assert.Equal(copyWithoutRemovalSupport, CreateHelper(false).CanStreamCopyVideo(state, state.VideoStream));
+ }
+ }
+ }
+
+ [Fact]
+ public void GetBitStreamArgs_ValidDovi_PreservesMetadata()
+ {
+ var state = CreateState("hevc", "smpte2084");
+ state.VideoStream.ColorSpace = "bt2020nc";
+ state.VideoStream.ColorPrimaries = "bt2020";
+ state.BaseRequest.VideoRangeType = "DOVIWithEL";
+ var helper = CreateHelper(true);
+
+ Assert.False(helper.IsDoviRemoved(state));
+ Assert.Equal("-bsf:v hevc_mp4toannexb", helper.GetBitStreamArgs(state, MediaStreamType.Video));
+ }
+
+ private static EncodingJobInfo CreateState(string codec, string? transfer)
+ {
+ var stream = new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ Codec = codec,
+ Width = 1920,
+ Height = 1080,
+ BitDepth = 10,
+ DvProfile = codec == "hevc" ? 7 : 10,
+ DvBlSignalCompatibilityId = codec == "hevc" ? 6 : 1,
+ RpuPresentFlag = 1,
+ BlPresentFlag = 1,
+ ColorSpace = "bt709",
+ ColorPrimaries = "bt709",
+ ColorTransfer = transfer
+ };
+
+ return new EncodingJobInfo(TranscodingJobType.Hls)
+ {
+ VideoStream = stream,
+ MediaSource = new MediaSourceInfo { Container = "mkv", MediaStreams = [stream] },
+ BaseRequest = new VideoRequestDto(),
+ OutputVideoCodec = "copy",
+ IsVideoRequest = true,
+ IsInputVideo = true
+ };
+ }
+
+ private static EncodingHelper CreateHelper(bool supportsRemoval)
+ {
+ var encoder = new Mock<IMediaEncoder>();
+ encoder.Setup(x => x.SupportsBitStreamFilterWithOption(It.IsAny<BitStreamFilterOptionType>())).Returns(supportsRemoval);
+ encoder.Setup(x => x.SupportsFilter("tonemapx")).Returns(true);
+ encoder.SetupGet(x => x.EncoderVersion).Returns(new Version(8, 1));
+
+ return new EncodingHelper(
+ Mock.Of<IApplicationPaths>(),
+ encoder.Object,
+ Mock.Of<ISubtitleEncoder>(),
+ Mock.Of<IConfiguration>(),
+ Mock.Of<IConfigurationManager>(),
+ Mock.Of<IPathManager>());
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs
new file mode 100644
index 0000000000..586db2dd50
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs
@@ -0,0 +1,40 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using Moq;
+using Xunit;
+using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding;
+
+public class EncodingHelperInferAudioCodecTests
+{
+ [Theory]
+ // Manifests and other containers that carry no inferable audio codec.
+ [InlineData("m3u8", "aac")]
+ [InlineData("mpd", "aac")]
+ [InlineData("wtv", "aac")]
+ [InlineData("", "aac")]
+ // Containers with a well known audio codec.
+ [InlineData("mp4", "aac")]
+ [InlineData("mkv", "aac")]
+ [InlineData("webm", "opus")]
+ [InlineData("ts", "mp3")]
+ // Containers named after the codec they carry.
+ [InlineData("flac", "flac")]
+ [InlineData("opus", "opus")]
+ [InlineData("ac3", "ac3")]
+ public void InferAudioCodec_ReturnsAnAudioCodec(string container, string expected)
+ {
+ Assert.Equal(expected, Create().InferAudioCodec(container));
+ }
+
+ private static EncodingHelper Create()
+ => new(
+ Mock.Of<IApplicationPaths>(),
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<ISubtitleEncoder>(),
+ Mock.Of<IConfiguration>(),
+ Mock.Of<IConfigurationManager>(),
+ Mock.Of<IPathManager>());
+}
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj b/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj
new file mode 100644
index 0000000000..b6dc5dfb92
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
+ <PropertyGroup>
+ <ProjectGuid>{E24A279C-9A37-419A-8F9C-853C11FBE753}</ProjectGuid>
+ <OutputType>Exe</OutputType>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit.v3" />
+ <PackageReference Include="xunit.runner.visualstudio">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ <PackageReference Include="coverlet.collector">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="../../src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs
new file mode 100644
index 0000000000..30b7983ece
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs
@@ -0,0 +1,99 @@
+using System.IO;
+using Xunit;
+
+namespace Jellyfin.Drawing.Skia.Tests;
+
+public static class SvgSecurityValidatorTests
+{
+ public static TheoryData<string> ExternalReferenceSvgs => new()
+ {
+ // SSRF via <image> (xlink:href and plain href)
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='http://169.254.169.254/latest/meta-data/' width='16' height='16'/></svg>",
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><image href='https://example.invalid/a.png' width='16' height='16'/></svg>",
+ // Local file disclosure
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///etc/passwd' width='16' height='16'/></svg>",
+ // Memory exhaustion DoS
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///dev/urandom' width='16' height='16'/></svg>",
+ // <use> external reference
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><use xlink:href='http://example.invalid/c.svg#a'/></svg>",
+ // CSS url() external reference in an attribute
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' style=\"fill:url(http://example.invalid/d.svg#g)\"/></svg>",
+ // @import in a style block
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><style>@import 'http://example.invalid/e.css';</style><rect width='16' height='16'/></svg>",
+ // Relative path traversal (resolves against the document location -> local file read)
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='../../../../etc/hosts' width='16' height='16'/></svg>",
+ // XXE via external entity
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&xxe;</text></svg>",
+ // Entity-expansion (billion laughs) denial of service
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY a 'aaaaaaaaaa'><!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'><!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'><!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'><!ENTITY e '&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;'><!ENTITY f '&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&f;</text></svg>",
+ // Nested SVG in a base64 data: URI whose inner document references an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jz48aW1hZ2UgeGxpbms6aHJlZj0naHR0cDovL2V4YW1wbGUuaW52YWxpZC9uZXN0ZWQucG5nJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jy8+PC9zdmc+' width='16' height='16'/></svg>",
+ // Nested SVG in a URL-encoded (non-base64) data: URI referencing an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20xmlns%3Axlink%3D%27http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%27%3E%3Cimage%20xlink%3Ahref%3D%27file%3A%2F%2F%2Fetc%2Fpasswd%27%2F%3E%3C%2Fsvg%3E' width='16' height='16'/></svg>",
+ // Nested gzip-compressed (svgz) data: URI whose inner document references an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/23OwQrDIBAE0F/x5s217aWK8V+E2N2laiWRKP36Nin0lNvAPIZx64Zi5FTWSVJr1QL03lW/qdeCcNVaw1fIH7EjcXmewYsxBo5Wis5zo0nepaDISG2P3nEOGMVBLC3x8V+JI+SaouKyhcQz4FvVgucz4N1+x38AdK4P3LYAAAA=' width='16' height='16'/></svg>",
+ };
+
+ public static TheoryData<string> SafeSvgs => new()
+ {
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='red'/></svg>",
+ // Same-document fragment references are allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><defs><linearGradient id='g'/></defs><rect width='16' height='16' fill='url(#g)'/><use xlink:href='#g'/></svg>",
+ // Inline data URIs are allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' width='16' height='16'/></svg>",
+ // A DOCTYPE without external entities is allowed
+ "<?xml version='1.0'?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>",
+ // An internal general entity with no external reference is allowed (and is expanded by the renderer)
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY col 'red'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='&col;'/></svg>",
+ // A nested data:image/svg+xml payload that is itself self-contained is allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc4JyBoZWlnaHQ9JzgnPjxyZWN0IHdpZHRoPSc4JyBoZWlnaHQ9JzgnIGZpbGw9J2JsdWUnLz48L3N2Zz4=' width='16' height='16'/></svg>",
+ // A self-contained gzip-compressed (svgz) data: URI is allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/22Muw6AIAwAf6VbN0p0MQb4GBWBBB+Bav18ZXe75C5n6h3g2fJeLUbmcyQSESW9OkqgTmtNX4EgaeFocUCIPoXIDZ0pfuZfBWvK2eKUL4/kTHu4F2NB6oFrAAAA' width='16' height='16'/></svg>",
+ };
+
+ [Theory]
+ [MemberData(nameof(ExternalReferenceSvgs))]
+ public static void IsSafe_ExternalReference_ReturnsFalse(string svg)
+ {
+ var path = WriteTemp(svg);
+ try
+ {
+ Assert.False(SvgSecurityValidator.IsSafe(path, out var reason));
+ Assert.NotNull(reason);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(SafeSvgs))]
+ public static void IsSafe_NoExternalReference_ReturnsTrue(string svg)
+ {
+ var path = WriteTemp(svg);
+ try
+ {
+ Assert.True(SvgSecurityValidator.IsSafe(path, out var reason));
+ Assert.Null(reason);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public static void IsSafe_MissingFile_ReturnsFalse()
+ {
+ Assert.False(SvgSecurityValidator.IsSafe(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Path.GetRandomFileName() + ".svg"), out var reason));
+ Assert.NotNull(reason);
+ }
+
+ private static string WriteTemp(string svg)
+ {
+ var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".svg");
+ File.WriteAllText(path, svg);
+ return path;
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs
new file mode 100644
index 0000000000..4487a5ff2b
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.TunerHosts;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests
+{
+ public class M3UTunerHostTests
+ {
+ [Theory]
+ // A manifest is not a byte stream, so it must never be offered for direct play.
+ [InlineData("http://example.com/live/1234.m3u8", false)]
+ [InlineData("http://example.com/live/1234.m3u8?token=abc", false)]
+ [InlineData("http://example.com/live/1234.mpd", false)]
+ // Byte streams are unaffected.
+ [InlineData("http://example.com/live/1234.ts", true)]
+ [InlineData("http://example.com/live/1234", true)]
+ public async Task GetChannelStreamMediaSources_ManifestPath_DisablesDirectPlay(string path, bool expectDirectPlay)
+ {
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.Http);
+
+ var host = new TestableM3UTunerHost(
+ Mock.Of<IServerConfigurationManager>(),
+ mediaSourceManager.Object,
+ Mock.Of<ILogger<M3UTunerHost>>(),
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IHttpClientFactory>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<INetworkManager>(),
+ Mock.Of<IStreamHelper>());
+
+ var sources = await host.GetMediaSources(
+ new TunerHostInfo { TunerCount = 0, EnableStreamLooping = false },
+ new ChannelInfo { Path = path });
+
+ Assert.Equal(expectDirectPlay, sources[0].SupportsDirectPlay);
+ }
+
+ private sealed class TestableM3UTunerHost : M3UTunerHost
+ {
+ public TestableM3UTunerHost(
+ IServerConfigurationManager config,
+ IMediaSourceManager mediaSourceManager,
+ ILogger<M3UTunerHost> logger,
+ IFileSystem fileSystem,
+ IHttpClientFactory httpClientFactory,
+ IServerApplicationHost appHost,
+ INetworkManager networkManager,
+ IStreamHelper streamHelper)
+ : base(config, mediaSourceManager, logger, fileSystem, httpClientFactory, appHost, networkManager, streamHelper)
+ {
+ }
+
+ public Task<List<MediaSourceInfo>> GetMediaSources(TunerHostInfo tuner, ChannelInfo channel)
+ => GetChannelStreamMediaSources(tuner, channel, CancellationToken.None);
+ }
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs
new file mode 100644
index 0000000000..141164815c
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.MediaEncoding.Encoder;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.MediaEncoding.Tests.Encoder;
+
+public class ProcessWrapperTests
+{
+ [Fact]
+ public async Task ExitedProcess_StaysUsableForTheCallerThatStartedIt()
+ {
+ using var process = CreateProcess();
+ using var exitHandled = new ManualResetEventSlim(false);
+
+ using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
+ {
+ // Subscribed after the wrapper, so by the time this is set the wrapper's own handler has
+ // already run: whatever it does to the process has happened.
+ process.Exited += (_, _) => exitHandled.Set();
+
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ Assert.True(exitHandled.Wait(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken), "The process never raised Exited.");
+
+ // The caller still owns the process here. Disposing it from the exit handler handed
+ // whoever exited quickest an ObjectDisposedException out of these three lines.
+ var output = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ Assert.Equal("jellyfin", output.Trim());
+
+ Assert.True(wrapper.HasExited);
+ Assert.Equal(3, wrapper.ExitCode);
+ }
+ }
+
+ [Fact]
+ public async Task ExitState_IsReadableBeforeTheExitEventArrives()
+ {
+ using var process = CreateProcess();
+
+ using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
+ {
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ // The exit event is raised on the thread pool and can lag behind the wait that just
+ // returned, so neither of these may depend on it having arrived.
+ Assert.True(wrapper.HasExited);
+ Assert.Equal(3, wrapper.ExitCode);
+ }
+ }
+
+ [Fact]
+ public async Task ExitCode_SurvivesDisposal()
+ {
+ using var process = CreateProcess();
+ var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder());
+
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ var exitCode = wrapper.ExitCode;
+ wrapper.Dispose();
+
+ Assert.Equal(exitCode, wrapper.ExitCode);
+ Assert.True(wrapper.HasExited);
+ }
+
+ private static MediaEncoder CreateEncoder()
+ => new(
+ Mock.Of<ILogger<MediaEncoder>>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IBlurayExaminer>(),
+ Mock.Of<ILocalizationManager>(),
+ new ConfigurationBuilder().Build(),
+ Mock.Of<IServerConfigurationManager>());
+
+ // Writes to stdout and exits immediately with a non-zero code, standing in for the ffprobe that
+ // rejects a file outright - the process that used to win the race against its own caller.
+ private static Process CreateProcess()
+ {
+ var startInfo = OperatingSystem.IsWindows()
+ ? new ProcessStartInfo("cmd.exe", "/c echo jellyfin & exit 3")
+ : new ProcessStartInfo("/bin/sh", "-c \"printf 'jellyfin\\n'; exit 3\"");
+
+ startInfo.CreateNoWindow = true;
+ startInfo.UseShellExecute = false;
+ startInfo.RedirectStandardOutput = true;
+
+ return new Process { StartInfo = startInfo, EnableRaisingEvents = true };
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs
new file mode 100644
index 0000000000..dfd1eb2e85
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs
@@ -0,0 +1,104 @@
+using System;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Model.Tests.Dlna;
+
+public class StreamBuilderManifestContainerTests
+{
+ [Theory]
+ // A manifest describes a stream instead of carrying one, so it can never be direct played,
+ // even when the client claims to support the container.
+ [InlineData("hls")]
+ [InlineData("hls,applehttp")]
+ [InlineData("applehttp")]
+ [InlineData("dash")]
+ public void GetOptimalVideoStream_ManifestContainer_DoesNotDirectPlay(string container)
+ {
+ var streamInfo = BuildFor(container);
+
+ Assert.NotNull(streamInfo);
+ Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod);
+ }
+
+ [Fact]
+ public void GetOptimalVideoStream_ByteStreamContainer_StillDirectPlays()
+ {
+ var streamInfo = BuildFor("mp4");
+
+ Assert.NotNull(streamInfo);
+ Assert.Equal(PlayMethod.DirectPlay, streamInfo.PlayMethod);
+ }
+
+ private static StreamInfo? BuildFor(string container)
+ {
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "test-source",
+ Path = "http://example.com/live/channel",
+ Protocol = MediaProtocol.Http,
+ Container = container,
+ SupportsDirectPlay = true,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true,
+ IsInfiniteStream = true,
+ IsRemote = true,
+ MediaStreams =
+ [
+ new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264" },
+ new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac" }
+ ]
+ };
+
+ var profile = new DeviceProfile
+ {
+ Name = "Manifest aware client",
+ DirectPlayProfiles =
+ [
+ new DirectPlayProfile
+ {
+ Type = DlnaProfileType.Video,
+ Container = "mp4,hls,applehttp,dash",
+ VideoCodec = "h264",
+ AudioCodec = "aac"
+ }
+ ],
+ TranscodingProfiles =
+ [
+ new TranscodingProfile
+ {
+ Type = DlnaProfileType.Video,
+ Context = EncodingContext.Streaming,
+ Protocol = MediaStreamProtocol.hls,
+ Container = "ts",
+ VideoCodec = "h264",
+ AudioCodec = "aac"
+ }
+ ]
+ };
+
+ var options = new MediaOptions
+ {
+ ItemId = new Guid("11D229B7-2D48-4B95-9F9B-49F6AB75E613"),
+ MediaSourceId = mediaSource.Id,
+ MediaSources = [mediaSource],
+ DeviceId = "test-deviceId",
+ Profile = profile,
+ AllowAudioStreamCopy = true,
+ AllowVideoStreamCopy = true,
+ EnableDirectStream = false // This is disabled in server
+ };
+
+ var transcodeSupport = new Mock<ITranscoderSupport>();
+
+ return new StreamBuilder(transcodeSupport.Object, new NullLogger<StreamBuilderManifestContainerTests>())
+ .GetOptimalVideoStream(options);
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs
new file mode 100644
index 0000000000..f264e5a019
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs
@@ -0,0 +1,129 @@
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Entities;
+using Xunit;
+
+namespace Jellyfin.Model.Tests.Entities;
+
+public class MediaStreamVideoRangeTests
+{
+ [Theory]
+ [InlineData(7, 6, "smpte2084", false, VideoRangeType.DOVIWithEL)]
+ [InlineData(7, 6, "smpte2084", true, VideoRangeType.DOVIWithELHDR10Plus)]
+ [InlineData(8, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(8, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)]
+ [InlineData(8, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)]
+ [InlineData(10, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(10, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)]
+ [InlineData(10, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)]
+ [InlineData(8, 1, "SMPTE2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(8, 4, "ARIB-STD-B67", false, VideoRangeType.DOVIWithHLG)]
+ public void GetVideoColorRange_ValidDovi_PreservesRangeType(
+ int profile, int compatibilityId, string transfer, bool hdr10Plus, VideoRangeType expected)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "BT2020NC", transfer, "BT2020", hdr10Plus);
+
+ Assert.Equal((VideoRange.HDR, expected), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData("bt709", "bt709", "bt709", VideoRange.SDR)]
+ [InlineData("bt2020nc", "bt709", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", null, "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "unknown", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "bt2020-10", "bt2020", VideoRange.SDR)]
+ [InlineData(null, null, null, VideoRange.SDR)]
+ [InlineData("bt709", "smpte2084", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "smpte2084", "bt709", VideoRange.HDR)]
+ [InlineData(null, "smpte2084", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "smpte2084", null, VideoRange.HDR)]
+ [InlineData("bt709", "arib-std-b67", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "arib-std-b67", "bt709", VideoRange.HDR)]
+ [InlineData(null, "arib-std-b67", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "arib-std-b67", null, VideoRange.HDR)]
+ public void GetVideoColorRange_InvalidDoviColors_UsesBaseLayerRange(
+ string? space, string? transfer, string? primaries, VideoRange expected)
+ {
+ // Cover every HDR-compatible DV profile, including the HDR10+ variants.
+ foreach (var (profile, compatibilityId) in new[] { (7, 6), (8, 1), (8, 4), (10, 1), (10, 4) })
+ {
+ foreach (var hdr10Plus in new[] { false, true })
+ {
+ var stream = CreateDovi(profile, compatibilityId, space, transfer, primaries, hdr10Plus);
+
+ Assert.Equal(expected, stream.VideoRange);
+ Assert.Equal(VideoRangeType.DOVIInvalid, stream.VideoRangeType);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(7, 6, "arib-std-b67")]
+ [InlineData(8, 1, "arib-std-b67")]
+ [InlineData(8, 4, "smpte2084")]
+ [InlineData(10, 1, "arib-std-b67")]
+ [InlineData(10, 4, "smpte2084")]
+ public void GetVideoColorRange_WrongHdrTransfer_InvalidButStillHdr(int profile, int compatibilityId, string transfer)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "bt2020nc", transfer, "bt2020", true);
+
+ Assert.Equal((VideoRange.HDR, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData(5, 0, null, VideoRange.HDR, VideoRangeType.DOVI)]
+ [InlineData(10, 0, null, VideoRange.HDR, VideoRangeType.DOVI)]
+ [InlineData(8, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)]
+ [InlineData(10, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)]
+ public void GetVideoColorRange_OtherDoviProfiles_PreservesClassification(
+ int profile, int compatibilityId, string? transfer, VideoRange range, VideoRangeType rangeType)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "bt709", transfer, "bt709", false);
+
+ Assert.Equal((range, rangeType), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData(8, null, VideoRange.SDR)]
+ [InlineData(8, "bt709", VideoRange.SDR)]
+ [InlineData(8, "smpte2084", VideoRange.HDR)]
+ [InlineData(10, null, VideoRange.SDR)]
+ [InlineData(10, "arib-std-b67", VideoRange.HDR)]
+ public void GetVideoColorRange_InvalidCompatibilityId_UsesBaseLayerRange(int profile, string? transfer, VideoRange expected)
+ {
+ var stream = CreateDovi(profile, 6, "bt2020nc", transfer, "bt2020", false);
+
+ Assert.Equal((expected, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData("bt709", false, VideoRange.SDR, VideoRangeType.SDR)]
+ [InlineData(null, false, VideoRange.SDR, VideoRangeType.SDR)]
+ [InlineData("smpte2084", false, VideoRange.HDR, VideoRangeType.HDR10)]
+ [InlineData("smpte2084", true, VideoRange.HDR, VideoRangeType.HDR10Plus)]
+ [InlineData("arib-std-b67", false, VideoRange.HDR, VideoRangeType.HLG)]
+ public void GetVideoColorRange_WithoutDovi_PreservesClassification(
+ string? transfer, bool hdr10Plus, VideoRange range, VideoRangeType rangeType)
+ {
+ var stream = new MediaStream { Type = MediaStreamType.Video, ColorTransfer = transfer, Hdr10PlusPresentFlag = hdr10Plus };
+
+ Assert.Equal((range, rangeType), stream.GetVideoColorRange());
+ stream.Type = MediaStreamType.Audio;
+ Assert.Equal((VideoRange.Unknown, VideoRangeType.Unknown), stream.GetVideoColorRange());
+ }
+
+ private static MediaStream CreateDovi(int profile, int compatibilityId, string? space, string? transfer, string? primaries, bool hdr10Plus)
+ => new()
+ {
+ Type = MediaStreamType.Video,
+ DvProfile = profile,
+ DvBlSignalCompatibilityId = compatibilityId,
+ RpuPresentFlag = 1,
+ BlPresentFlag = 1,
+ ElPresentFlag = profile == 7 ? 1 : 0,
+ ColorSpace = space,
+ ColorTransfer = transfer,
+ ColorPrimaries = primaries,
+ Hdr10PlusPresentFlag = hdr10Plus
+ };
+}
diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
index 7e708c681d..4236749423 100644
--- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
@@ -74,6 +74,9 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("Season 5/S05E23 11-59 [HDTV-1080p][x265 AC3].mkv", null)]
[InlineData("Season 5/S05E23 11-59 [HDTV-1080p][HEVC AC3].mkv", null)]
[InlineData("Season 1/S01E01 1-23-45 [Bluray-1080p][AV1 Opus].mkv", null)]
+ // Episode markers in the episode title must not be read as an episode range
+ [InlineData("Season 03/Star Trek Enterprise (2001) - S03E21 - E2 (1080p BluRay x265).mkv", null)]
+ [InlineData("Season 02/Series Name (2001) - S02E10 - E5 [WEBRip-1080p].mkv", null)]
public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber)
{
var result = _episodePathParser.Parse(filename, false);
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 5749944fcd..248b236df8 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@@ -377,6 +378,116 @@ namespace Jellyfin.Providers.Tests.Manager
GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true);
}
+ [Fact]
+ public async Task QueueRefresh_ManyItemsQueuedFromManyThreads_ProcessesEveryOne()
+ {
+ const int ItemCount = 2000;
+
+ var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray();
+ var processed = new ConcurrentBag<Guid>();
+ var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>()))
+ .Returns((Guid id) =>
+ {
+ // Returning null drains the entry without the whole refresh machinery.
+ processed.Add(id);
+ if (processed.Count == ItemCount)
+ {
+ allProcessed.TrySetResult();
+ }
+
+ return null;
+ });
+
+ using var providerManager = GetProviderManager(libraryManager: libraryManager.Object);
+
+ await Parallel.ForEachAsync(
+ queued,
+ TestContext.Current.CancellationToken,
+ (id, _) =>
+ {
+ providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal);
+ return ValueTask.CompletedTask;
+ });
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(30));
+
+ try
+ {
+ await allProcessed.Task.WaitAsync(timeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Fall through so the assertions report what was lost.
+ }
+
+ Assert.Empty(providerManager.GetRefreshQueue());
+ Assert.Equal(queued.Order().ToArray(), processed.Order().ToArray());
+ }
+
+ [Fact]
+ public async Task QueueRefresh_RefreshCancelsForItsOwnReasons_KeepsDrainingTheQueue()
+ {
+ // A provider timeout arrives as an OperationCanceledException, indistinguishable from
+ // a shutdown; treating it as one would strand the rest of the queue.
+ const int ItemCount = 200;
+
+ var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray();
+ var processed = new ConcurrentBag<Guid>();
+ var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var allQueued = new ManualResetEventSlim(false);
+ var cancelledOnce = false;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>()))
+ .Returns((Guid id) =>
+ {
+ if (!cancelledOnce)
+ {
+ cancelledOnce = true;
+
+ // Hold the first entry until the whole batch is queued.
+ allQueued.Wait(TimeSpan.FromSeconds(30));
+ throw new OperationCanceledException("provider timed out");
+ }
+
+ processed.Add(id);
+ if (processed.Count == ItemCount - 1)
+ {
+ allProcessed.TrySetResult();
+ }
+
+ return null;
+ });
+
+ using var providerManager = GetProviderManager(libraryManager: libraryManager.Object);
+
+ foreach (var id in queued)
+ {
+ providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal);
+ }
+
+ allQueued.Set();
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(30));
+
+ try
+ {
+ await allProcessed.Task.WaitAsync(timeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Fall through so the assertions report what was stranded.
+ }
+
+ Assert.Empty(providerManager.GetRefreshQueue());
+ Assert.Equal(ItemCount - 1, processed.Count);
+ }
+
private static void GetMetadataProviders_CanRefreshMetadata_Tester(
string providerType,
bool expected,
@@ -554,15 +665,20 @@ namespace Jellyfin.Providers.Tests.Manager
private static ProviderManager GetProviderManager(
ServerConfiguration? serverConfiguration = null,
LibraryOptions? libraryOptions = null,
- IBaseItemManager? baseItemManager = null)
+ IBaseItemManager? baseItemManager = null,
+ ILibraryManager? libraryManager = null)
{
var serverConfigurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Strict);
serverConfigurationManager.Setup(i => i.Configuration)
.Returns(serverConfiguration ?? new ServerConfiguration());
- var libraryManager = new Mock<ILibraryManager>(MockBehavior.Strict);
- libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
- .Returns(libraryOptions ?? new LibraryOptions());
+ if (libraryManager is null)
+ {
+ var libraryManagerMock = new Mock<ILibraryManager>(MockBehavior.Strict);
+ libraryManagerMock.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
+ .Returns(libraryOptions ?? new LibraryOptions());
+ libraryManager = libraryManagerMock.Object;
+ }
var providerManager = new ProviderManager(
Mock.Of<IHttpClientFactory>(),
@@ -572,7 +688,7 @@ namespace Jellyfin.Providers.Tests.Manager
_logger,
Mock.Of<IFileSystem>(),
Mock.Of<IServerApplicationPaths>(),
- libraryManager.Object,
+ libraryManager,
baseItemManager!,
Mock.Of<ILyricManager>(),
Mock.Of<IMemoryCache>(),
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
index 876f18741f..ce451861ef 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
@@ -179,6 +179,146 @@ public class MediaInfoResolverTests
Assert.Empty(streams);
}
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubPair_OnlyReturnsIdxFile()
+ {
+ // VobSub (.sub) payloads only carry per-track language metadata when read
+ // alongside their paired .idx index file. When both are present, only the
+ // .idx file should be returned so it (not the raw .sub) gets probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxWithoutMatchingSub_DoesNotReturnIdxFile()
+ {
+ // An .idx file with no paired .sub cannot be probed for anything, so it must be
+ // left out entirely rather than surfaced as a doomed-to-fail probe candidate.
+ // Surfacing it anyway would also make it "exist" from Jellyfin's perspective
+ // even after the real .sub is deleted, preventing stale subtitle stream data
+ // from ever being cleared on a rescan.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = GetDirectoryServiceForExternalFile("My.Video.idx");
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
+
+ Assert.Empty(streams);
+ }
+
+ [Fact]
+ public void GetExternalFiles_StandaloneSubWithoutIdx_StillReturnsSubFile()
+ {
+ // Guards against the .idx/.sub pairing suppression firing when there is no
+ // .idx sidecar at all - a lone .sub file must still be returned.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = GetDirectoryServiceForExternalFile("My.Video.sub");
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubInDifferentDirectories_DoesNotPair()
+ {
+ // A same-named .idx and .sub split across the video folder and the internal
+ // metadata folder cannot be paired by ffprobe (it only looks next to the .idx),
+ // so the .sub must still be returned, but the orphaned .idx (no sibling .sub in
+ // its own directory) must be left out since it cannot be probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { MetadataDirectoryPath + "/My.Video.idx" });
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubWithMatchingLanguageFlag_SuppressesSub()
+ {
+ // A .idx/.sub pair sharing the same filename flags (e.g. a language token) should
+ // still pair and suppress the .sub, just like an unflagged pair.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.en.idx", VideoDirectoryPath + "/My.Video.en.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubWithMismatchedNames_DoesNotPair()
+ {
+ // An .idx and .sub with different basenames (e.g. differing filename flags) are not
+ // a pair ffprobe would resolve. The .sub must still be returned, but the orphaned
+ // .idx (no same-named sibling .sub) must be left out since it cannot be probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.en.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
[Theory]
[InlineData("https://url.com/My.Video.mkv")]
[InlineData(VideoDirectoryPath)] // valid but no files found for this test
diff --git a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
index 8f5b1b3c48..ea762256db 100644
--- a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
+++ b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
@@ -1,5 +1,6 @@
using System;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
@@ -15,9 +16,23 @@ using Xunit;
namespace Jellyfin.Providers.Tests.TV;
-public class EpisodeMetadataServiceTests
+// put tests that mock the static LibraryManager in the same collection to avoid test interference
+[Collection("LibraryManagerTests")]
+public sealed class EpisodeMetadataServiceTests : IDisposable
{
private readonly TestEpisodeMetadataService _service = new();
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public EpisodeMetadataServiceTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ BaseItem.LibraryManager = Mock.Of<ILibraryManager>();
+ }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
[Fact]
public void MergeData_ProviderSeasonOverridesPathDerivedSeason()
@@ -88,6 +103,59 @@ public class EpisodeMetadataServiceTests
Assert.Equal(1, target.Item.ParentIndexNumber);
}
+ [Theory]
+ [InlineData(2, 1)]
+ [InlineData(22, 21)]
+ [InlineData(21, 2)] // e.g. "Series - S03E21 - E2 (1080p BluRay x265).mkv", where "E2" is the episode title
+ public void BeforeSave_ReversedEpisodeRange_ClearsIndexNumberEnd(int indexNumber, int indexNumberEnd)
+ {
+ var item = new Episode
+ {
+ IndexNumber = indexNumber,
+ IndexNumberEnd = indexNumberEnd
+ };
+
+ var updateType = _service.BeforeSave(item);
+
+ // The episode number identifies the item, so it is kept and the impossible range is dropped
+ Assert.Equal(indexNumber, item.IndexNumber);
+ Assert.Null(item.IndexNumberEnd);
+ Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport));
+ }
+
+ [Fact]
+ public void BeforeSave_EpisodeRangeWithoutStart_ClearsIndexNumberEnd()
+ {
+ var item = new Episode
+ {
+ IndexNumber = null,
+ IndexNumberEnd = 2
+ };
+
+ var updateType = _service.BeforeSave(item);
+
+ Assert.Null(item.IndexNumberEnd);
+ Assert.Null(item.IndexNumber);
+ Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport));
+ }
+
+ [Theory]
+ [InlineData(1, 2)] // Regular multi episode file
+ [InlineData(1, 1)] // Degenerate but not contradictory
+ public void BeforeSave_ValidEpisodeRange_KeepsIndexNumberEnd(int indexNumber, int indexNumberEnd)
+ {
+ var item = new Episode
+ {
+ IndexNumber = indexNumber,
+ IndexNumberEnd = indexNumberEnd
+ };
+
+ _service.BeforeSave(item);
+
+ Assert.Equal(indexNumber, item.IndexNumber);
+ Assert.Equal(indexNumberEnd, item.IndexNumberEnd);
+ }
+
private sealed class TestEpisodeMetadataService : EpisodeMetadataService
{
public TestEpisodeMetadataService()
@@ -106,5 +174,10 @@ public class EpisodeMetadataServiceTests
{
MergeData(source, target, Array.Empty<MetadataField>(), replaceData, mergeMetadataSettings);
}
+
+ public ItemUpdateType BeforeSave(Episode item)
+ {
+ return BeforeSaveInternal(item, false, ItemUpdateType.None);
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index bdac59c013..679e6d17e3 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -154,7 +154,7 @@ public class DtoServiceTests
.Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny<IReadOnlyList<Guid>>(), user))
.Returns(new Dictionary<Guid, (int Played, int Total)> { [season.Id] = (playedCount, totalCount) });
_libraryManagerMock
- .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<Guid?>()))
+ .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()))
.Returns(new Dictionary<Guid, int> { [season.Id] = childCount });
return (season, user);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs
new file mode 100644
index 0000000000..cdb261de8d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.EntryPoints;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
+
+public class LibraryChangedNotifierTests
+{
+ // How long a test waits for the notifier's timer callback to run. Generous: the assertions are
+ // about a batch being sent at all, not about how promptly.
+ private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
+
+ private readonly Mock<ILibraryManager> _libraryManager = new();
+ private readonly Mock<IServerConfigurationManager> _configurationManager = new();
+ private readonly Mock<ISessionManager> _sessionManager = new();
+ private readonly Mock<IUserManager> _userManager = new();
+ private readonly Mock<IProviderManager> _providerManager = new();
+ private readonly ServerConfiguration _configuration = new();
+
+ private int _flushCount;
+
+ public LibraryChangedNotifierTests()
+ {
+ _configurationManager.SetupGet(e => e.Configuration).Returns(_configuration);
+
+ // Reading the session list is the first thing a flush does, so it stands in for "a batch was
+ // sent" without having to mock a whole user library behind it.
+ _sessionManager.SetupGet(e => e.Sessions)
+ .Returns(() =>
+ {
+ Interlocked.Increment(ref _flushCount);
+ return [];
+ });
+ }
+
+ [Fact]
+ public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow()
+ {
+ // Long enough that only the size cap can close the batch.
+ _configuration.LibraryUpdateDuration = 3600;
+
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++)
+ {
+ RaiseItemUpdated();
+ }
+
+ Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ [Fact]
+ public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow()
+ {
+ // A scan changes items continuously. The window must run from the first change of a batch, or
+ // the batch never closes and holds every item it named alive for the length of the scan.
+ _configuration.LibraryUpdateDuration = 1;
+
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
+ {
+ // Well below the window, and well below the size cap over the whole loop.
+ RaiseItemUpdated();
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ private LibraryChangedNotifier CreateNotifier()
+ => new(
+ _libraryManager.Object,
+ _configurationManager.Object,
+ _sessionManager.Object,
+ _userManager.Object,
+ NullLogger<LibraryChangedNotifier>.Instance,
+ _providerManager.Object);
+
+ // A folder passes the notifier's item filter without needing any of BaseItem's static services.
+ private void RaiseItemUpdated()
+ => _libraryManager.Raise(
+ e => e.ItemUpdated += null,
+ _libraryManager.Object,
+ new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } });
+
+ private async Task<bool> WaitForFlushAsync(int expected)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout)
+ {
+ if (Volatile.Read(ref _flushCount) >= expected)
+ {
+ return true;
+ }
+
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ return false;
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs
new file mode 100644
index 0000000000..0274398f89
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.EntryPoints;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Session;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
+
+public class UserDataChangeNotifierTests
+{
+ // How long a test waits for the notifier's timer callback to run. Generous: the assertions are
+ // about a batch being sent at all, not about how promptly.
+ private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
+
+ private readonly Mock<IUserDataManager> _userDataManager = new();
+ private readonly Mock<ISessionManager> _sessionManager = new();
+ private readonly Mock<IUserManager> _userManager = new();
+
+ private int _flushCount;
+
+ public UserDataChangeNotifierTests()
+ {
+ _sessionManager
+ .Setup(e => e.SendMessageToUserSessions(
+ It.IsAny<System.Collections.Generic.List<Guid>>(),
+ SessionMessageType.UserDataChanged,
+ It.IsAny<Func<UserDataChangeInfo>>(),
+ It.IsAny<CancellationToken>()))
+ .Callback(() => Interlocked.Increment(ref _flushCount))
+ .Returns(Task.CompletedTask);
+ }
+
+ [Fact]
+ public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow()
+ {
+ // A scan changes user data continuously. The window must run from the first change of a batch,
+ // or the batch never closes and holds every item it named alive for the length of the scan.
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ var userId = Guid.NewGuid();
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
+ {
+ // Well below the window, and well below the size cap over the whole loop.
+ RaiseUserDataSaved(userId);
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ private UserDataChangeNotifier CreateNotifier()
+ => new(_userDataManager.Object, _sessionManager.Object, _userManager.Object);
+
+ // A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the
+ // notifier ignores outright.
+ private void RaiseUserDataSaved(Guid userId)
+ => _userDataManager.Raise(
+ e => e.UserDataSaved += null,
+ _userDataManager.Object,
+ new UserDataSaveEventArgs
+ {
+ UserId = userId,
+ SaveReason = UserDataSaveReason.UpdateUserRating,
+ Item = new Folder { Id = Guid.NewGuid() }
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs
new file mode 100644
index 0000000000..0ca11eb58d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a
+/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a
+/// linked folder are only reachable by following the link and then the ancestor chain.
+/// </summary>
+public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture
+{
+ private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
+ private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
+ private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
+ private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+
+ private readonly BaseItemRepository _repository;
+
+ private readonly Guid _library = Guid.NewGuid();
+ private readonly Guid _collection = Guid.NewGuid();
+ private readonly Guid _series = Guid.NewGuid();
+ private readonly Guid _season = Guid.NewGuid();
+ private readonly Guid _episode = Guid.NewGuid();
+
+ // A movie the collection links directly, so the direct-child case is covered alongside the nested one.
+ private readonly Guid _collectionMovie = Guid.NewGuid();
+
+ // In the same library but outside the collection, as the control the assertions are read against.
+ private readonly Guid _otherSeries = Guid.NewGuid();
+ private readonly Guid _otherEpisode = Guid.NewGuid();
+
+ public BaseItemRepositoryDescendantFilterTests()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ Seed(ctx);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void DescendantOfId_ReachesEpisodesOfALinkedSeries()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ DescendantOfId = _collection,
+ IncludeItemTypes = [BaseItemKind.Episode]
+ });
+
+ Assert.Equal([_episode], ids);
+ }
+
+ [Fact]
+ public void DescendantOfId_ReturnsEveryLevelBelowTheCollection()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet();
+
+ Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order());
+ }
+
+ [Fact]
+ public void DescendantOfId_KeepsDirectlyLinkedChildren()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ DescendantOfId = _collection,
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal([_collectionMovie], ids);
+ }
+
+ [Fact]
+ public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() });
+
+ Assert.Empty(ids);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" });
+
+ // AncestorIds is a closure: production writes one row per ancestor, not just the parent.
+ AddAncestors(context, _series, _library);
+ AddAncestors(context, _season, _series, _library);
+ AddAncestors(context, _episode, _season, _series, _library);
+ AddAncestors(context, _collectionMovie, _library);
+ AddAncestors(context, _otherSeries, _library);
+ AddAncestors(context, _otherEpisode, _otherSeries, _library);
+
+ AddLink(context, _series, 0);
+ AddLink(context, _collectionMovie, 1);
+
+ context.SaveChanges();
+ }
+
+ private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
+ {
+ foreach (var ancestorId in ancestorIds)
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = ancestorId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+
+ private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder)
+ {
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _collection,
+ ChildId = childId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = sortOrder
+ });
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
new file mode 100644
index 0000000000..91148501ce
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
@@ -0,0 +1,174 @@
+using System;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture
+{
+ private readonly BaseItemRepository _repository;
+ private readonly string _audioTypeName;
+ private readonly string _movieTypeName;
+
+ public BaseItemRepositoryItemValueTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _audioTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ _repository = CreateBaseItemRepository(itemTypeLookup);
+ }
+
+ [Fact]
+ public void GetQueryFiltersLegacy_GroupsAndFiltersItemValues()
+ {
+ var firstItem = CreateMovieEntity(Guid.NewGuid(), "First");
+ var secondItem = CreateMovieEntity(Guid.NewGuid(), "Second");
+ var excludedItem = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Excluded Audio",
+ MediaType = "Audio",
+ IsMovie = false,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var firstTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Alpha",
+ CleanValue = "alpha"
+ };
+ var duplicateTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "alpha",
+ CleanValue = "alpha"
+ };
+ var secondTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Beta",
+ CleanValue = "beta"
+ };
+ var genre = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = "Genre Leak",
+ CleanValue = "genre leak"
+ };
+ var excludedTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Excluded Tag",
+ CleanValue = "excluded tag"
+ };
+ var excludedGenre = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = "Excluded Genre",
+ CleanValue = "excluded genre"
+ };
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(firstItem, secondItem, excludedItem);
+ context.ItemValues.AddRange(firstTag, duplicateTag, secondTag, genre, excludedTag, excludedGenre);
+ context.ItemValuesMap.AddRange(
+ CreateMap(firstItem, firstTag),
+ CreateMap(firstItem, duplicateTag),
+ CreateMap(secondItem, secondTag),
+ CreateMap(firstItem, genre),
+ CreateMap(excludedItem, excludedTag),
+ CreateMap(excludedItem, excludedGenre));
+ context.SaveChanges();
+ }
+
+ var result = _repository.GetQueryFiltersLegacy(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal(["Alpha", "Beta"], result.Tags);
+ Assert.Equal(["Genre Leak"], result.Genres);
+ }
+
+ [Fact]
+ public void GetGenreNames_GroupsAndFiltersMappedItemValues()
+ {
+ var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
+ var audio = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Audio",
+ MediaType = "Audio",
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var movieGenre = CreateItemValue(ItemValueType.Genre, "Movie Genre", "movie genre");
+ var duplicateMovieGenre = CreateItemValue(ItemValueType.Genre, "movie genre", "movie genre");
+ var musicGenre = CreateItemValue(ItemValueType.Genre, "Music Genre", "music genre");
+ var orphanedGenre = CreateItemValue(ItemValueType.Genre, "Orphaned Genre", "orphaned genre");
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(movie, audio);
+ context.ItemValues.AddRange(movieGenre, duplicateMovieGenre, musicGenre, orphanedGenre);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, movieGenre),
+ CreateMap(movie, duplicateMovieGenre),
+ CreateMap(audio, musicGenre));
+ context.SaveChanges();
+ }
+
+ Assert.Equal(["Movie Genre"], _repository.GetGenreNames());
+ Assert.Equal(["Music Genre"], _repository.GetMusicGenreNames());
+ }
+
+ private BaseItemEntity CreateMovieEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ {
+ return new ItemValueMap
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+ }
+
+ private static ItemValue CreateItemValue(ItemValueType type, string value, string cleanValue)
+ {
+ return new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = type,
+ Value = value,
+ CleanValue = cleanValue
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
index 947cf54d85..fea743f08e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -198,6 +198,78 @@ public sealed class ItemCountServiceTests : IDisposable
Assert.Equal(2, result[seriesB]);
}
+ [Fact]
+ public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason()
+ {
+ var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false);
+
+ var result = _service.GetChildCountBatch([seriesId, seasonId], null);
+
+ Assert.Equal(2, result[seasonId]);
+
+ // The series holds the season, not the episodes: counting those here would double them up.
+ Assert.Equal(1, result[seriesId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce()
+ {
+ var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false);
+
+ var result = _service.GetChildCountBatch([seriesId, seasonId], null);
+
+ Assert.Equal(2, result[seasonId]);
+ Assert.Equal(1, result[seriesId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem()
+ {
+ var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
+ var user = new User("count-test", "provider", "reset");
+
+ user.DisplayMissingEpisodes = true;
+ Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]);
+
+ // Nothing this user can open, so nothing to report.
+ user.DisplayMissingEpisodes = false;
+ Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_NoUser_CountsMissingEpisodes()
+ {
+ var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
+
+ Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]);
+ }
+
+ private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes)
+ {
+ var seriesId = Guid.NewGuid();
+ var seasonId = Guid.NewGuid();
+
+ using var context = CreateDbContext();
+ context.BaseItems.Add(CreateItem(seriesId));
+ context.BaseItems.Add(CreateItem(seasonId, seriesId));
+
+ // Flat: the episodes sit in the series folder, so ParentId points at the series and only
+ // SeasonId ties them to the season they belong to.
+ for (var i = 0; i < 2; i++)
+ {
+ var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId);
+ episode.Type = "MediaBrowser.Controller.Entities.TV.Episode";
+ episode.IsFolder = false;
+ episode.IsVirtualItem = virtualEpisodes;
+ episode.SeasonId = seasonId;
+ context.BaseItems.Add(episode);
+ }
+
+ context.SaveChanges();
+
+ return (seriesId, seasonId);
+ }
+
private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
{
var user = new User("count-test", "provider", "reset");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs
new file mode 100644
index 0000000000..7997c6d771
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistenceServiceSaveImagesTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public async Task SaveImagesAsync_ReplacesThePreviousImages()
+ {
+ var itemId = Guid.NewGuid();
+ Seed(itemId);
+
+ await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken);
+ await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken);
+
+ using var context = CreateDbContext();
+ var paths = context.BaseItemImageInfos
+ .Where(e => e.ItemId.Equals(itemId))
+ .Select(e => e.Path)
+ .ToList();
+
+ Assert.Equal(["/second.jpg"], paths);
+ }
+
+ [Fact]
+ public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp()
+ {
+ // A scan can delete the item between the refresh reading it and the images being written. That
+ // must not fail the whole refresh, and must not leave the images of an item that is gone.
+ var itemId = Guid.NewGuid();
+
+ await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId)));
+ }
+
+ private static BaseItem CreateItem(Guid itemId, string imagePath)
+ => new Folder
+ {
+ Id = itemId,
+ ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }]
+ };
+
+ private void Seed(Guid itemId)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = itemId,
+ Type = "Folder",
+ IsFolder = true
+ });
+ context.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
index 87efa8fea5..cfc9c9496c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
@@ -58,6 +59,8 @@ public abstract class SqliteDbTestFixture : IDisposable
{
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
return factory.Object;
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
new file mode 100644
index 0000000000..30f7bed208
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library.Validators;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Tests for how the people validator decides which credits need a person item and which person items
+/// nothing credits any more. Keying either half on the item's name rather than its id put the two halves
+/// in a loop that created, refreshed and deleted the same people on every run, so these pin the id.
+/// </summary>
+public class PeopleValidatorPartitionTests
+{
+ // Stands in for the real item-by-name id: derived from the credit name, case-insensitively, and
+ // from nothing else. The property that matters is that it does not depend on the item's own name.
+ private static Guid PersonId(string creditName)
+ {
+#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
+ var hash = System.Security.Cryptography.MD5.HashData(
+ System.Text.Encoding.Unicode.GetBytes(creditName.ToLowerInvariant()));
+#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms
+ return new Guid(hash);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ProviderRenamedThePerson_KeepsThemAndCreatesNothing()
+ {
+ // The credit still says "AURORA"; the item it made has been renamed to "Aurora" by the provider
+ // that refreshed it. Nothing about the library changed, so nothing should be created or deleted.
+ var credits = new[] { "AURORA" };
+ var existing = new HashSet<Guid> { PersonId("AURORA") };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Theory]
+ // Every shape of rename seen in the wild on a real library.
+ [InlineData("AURORA")]
+ [InlineData("Amir AboulEla")]
+ [InlineData("Miguel Ángel Fuentes")]
+ [InlineData("a‐ha")]
+ [InlineData("윤현민")]
+ public void PartitionCreditsByPersonId_CreditWithAnItem_IsNeverBothCreatedAndDeleted(string creditName)
+ {
+ var existing = new HashSet<Guid> { PersonId(creditName) };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId([creditName], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditWithNoItem_IsCreated()
+ {
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["Wanted Person"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Equal(["Wanted Person"], newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ItemNoCreditNames_IsDead()
+ {
+ var orphan = PersonId("Nobody Credits Me");
+ var existing = new HashSet<Guid> { PersonId("Credited"), orphan };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(["Credited"], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Equal([orphan], deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditsNormalizingOntoOneId_CreateOneItem()
+ {
+ // "AURORA" and "Aurora" are one person as far as the item-by-name id is concerned, so exactly
+ // one of them should create the item and neither should end up dead.
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["AURORA", "Aurora", "aurora"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Single(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_SecondRunAfterCreating_AsksForNothingFurther()
+ {
+ // The churn showed up as a run that never settled, so drive two rounds: whatever round one
+ // created must leave round two with nothing to do.
+ string[] credits = ["AURORA", "Amir AboulEla", "Miguel Ángel Fuentes"];
+ var existing = new HashSet<Guid>();
+
+ var (firstNames, firstDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+ Assert.Equal(3, firstNames.Count);
+ Assert.Empty(firstDead);
+
+ foreach (var created in firstNames)
+ {
+ existing.Add(PersonId(created));
+ }
+
+ var (secondNames, secondDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(secondNames);
+ Assert.Empty(secondDead);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
index feb2d8a625..67d924d152 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
@@ -62,6 +62,36 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.Equal(expectedId, actualId);
}
+ [Theory]
+ [InlineData("/media/Show/Season 01 [anidbid=11111]", "AniDB", "11111")]
+ [InlineData("/media/Show/Season 01 [anidbid-11111]", "AniDB", "11111")]
+ [InlineData("/media/Show/Season 02 [anilistid=22222]", "AniList", "22222")]
+ [InlineData("/media/Show/Season 02 (anilistid=22222)", "AniList", "22222")]
+ [InlineData("/media/Show/Season 03 [anisearchid=33333]", "AniSearch", "33333")]
+ public void Resolve_SeasonFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId)
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(providerKey, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
[Fact]
public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll()
{
@@ -140,6 +170,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _));
Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _));
Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _));
+ Assert.False(season.TryGetProviderId("AniDB", out _));
+ Assert.False(season.TryGetProviderId("AniList", out _));
+ Assert.False(season.TryGetProviderId("AniSearch", out _));
}
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
index a5a67046d1..f803c69af2 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
@@ -1,6 +1,9 @@
using System;
+using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
@@ -8,7 +11,9 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -108,4 +113,136 @@ public class SessionManagerTests
return data;
}
+
+ [Fact]
+ public async Task SendMessageCommand_Should_ThrowSecurityException_WhenControllingAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand(
+ attackerSession.Id,
+ victimSession.Id,
+ new MessageCommand { Header = "Custom Message", Text = "test exploit!" },
+ CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendMessageCommand_Should_Succeed_WhenAllowedToControlOtherUsers()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("controller", "default", "default");
+ attacker.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true);
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var controllingSession = await LogSessionActivity(sessionManager, attacker);
+
+ await sessionManager.SendMessageCommand(
+ controllingSession.Id,
+ victimSession.Id,
+ new MessageCommand { Header = "Custom Message", Text = "hello" },
+ CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task LogSessionActivity_Should_NotReuseAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ // Client name and device id are attacker controlled, so they must not identify a session on their own.
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.NotEqual(victimSession.Id, attackerSession.Id);
+ Assert.Equal(victim.Id, victimSession.UserId);
+ }
+
+ [Fact]
+ public async Task AddAdditionalUser_Should_ThrowSecurityException_WhenAttachingAnotherUser()
+ {
+ var attacker = new User("attacker", "default", "default");
+ var victim = new User("victim", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id));
+ }
+
+ [Fact]
+ public async Task AddAdditionalUser_Should_Succeed_WhenCallerIsAdministrator()
+ {
+ var admin = new User("admin", "default", "default");
+ admin.SetPermission(PermissionKind.IsAdministrator, true);
+ var guest = new User("guest", "default", "default");
+ await using var sessionManager = CreateSessionManager(admin, guest);
+
+ var adminSession = await LogSessionActivity(sessionManager, admin);
+
+ sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id);
+
+ Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id));
+ }
+
+ [Fact]
+ public async Task RemoveAdditionalUser_Should_ThrowSecurityException_WhenModifyingAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id));
+ }
+
+ [Fact]
+ public async Task ReportCapabilities_Should_ThrowSecurityException_WhenReportingForAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities()));
+ }
+
+ private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users)
+ {
+ var userManager = new Mock<IUserManager>();
+ foreach (var user in users)
+ {
+ userManager.Setup(i => i.GetUserById(user.Id)).Returns(user);
+ }
+
+ return new Emby.Server.Implementations.Session.SessionManager(
+ NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
+ Mock.Of<IEventManager>(),
+ Mock.Of<IUserDataManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILibraryManager>(),
+ userManager.Object,
+ Mock.Of<IMusicManager>(),
+ Mock.Of<IDtoService>(),
+ Mock.Of<IImageProcessor>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<IDeviceManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IHostApplicationLifetime>());
+ }
+
+ // All sessions are logged with the same client and device id on purpose, those values are taken
+ // from the request headers and are not bound to the access token of the calling user.
+ private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user)
+ => sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user);
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
index 32685556b2..05e8a40de1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
@@ -143,6 +143,36 @@ public class PlayQueueManagerTests
}
[Fact]
+ public void SetShuffleMode_SortedWhileAlreadySorted_KeepsPlayingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(1);
+ var expectedItemId = queue.GetPlayingItemId();
+
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+
+ Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void SetShuffleMode_SortedTwiceAfterShuffle_KeepsPlayingItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetPlayingItemByIndex(2);
+ var expectedItemId = queue.GetPlayingItemId();
+
+ queue.SetShuffleMode(GroupShuffleMode.Shuffle);
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+
+ Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
+ Assert.Equal(5, queue.GetPlaylist().Count);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
{
var queue = CreateQueue(2);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
new file mode 100644
index 0000000000..b1221f6f71
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Threading;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class SyncPlayManagerTests
+{
+ [Fact]
+ public void LeaveGroup_AfterJoiningTheSameGroupTwice_ClearsTheActiveSessionCounter()
+ {
+ var harness = new ManagerHarness();
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ Assert.True(harness.Manager.IsUserActive(harness.User.Id));
+
+ // A client that re-sends Join for the group it is already in must not be counted twice.
+ harness.Manager.JoinGroup(harness.Session, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public void LeaveGroup_AfterASingleJoin_ClearsTheActiveSessionCounter()
+ {
+ var harness = new ManagerHarness();
+
+ harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public void IsUserActive_WithTwoSessionsOfTheSameUser_TracksBothSeparately()
+ {
+ var harness = new ManagerHarness();
+ var second = harness.CreateSession("session-2");
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+ Assert.True(harness.Manager.IsUserActive(harness.User.Id));
+
+ harness.Manager.LeaveGroup(second, new LeaveGroupRequest(), CancellationToken.None);
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ private sealed class ManagerHarness
+ {
+ private readonly Mock<ISessionManager> _sessionManager = new();
+
+ public ManagerHarness()
+ {
+ var userManager = new Mock<IUserManager>();
+ var libraryManager = new Mock<ILibraryManager>();
+
+ User = new User("tester", "auth-provider", "pwdreset-provider");
+ userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User);
+
+ Manager = new SyncPlayManager(
+ NullLoggerFactory.Instance,
+ userManager.Object,
+ _sessionManager.Object,
+ libraryManager.Object);
+
+ Session = CreateSession("session-1");
+ }
+
+ public SyncPlayManager Manager { get; }
+
+ public User User { get; }
+
+ public SessionInfo Session { get; }
+
+ public SessionInfo CreateSession(string id)
+ {
+ return new SessionInfo(_sessionManager.Object, NullLogger.Instance)
+ {
+ Id = id,
+ UserId = User.Id,
+ UserName = User.Username
+ };
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
new file mode 100644
index 0000000000..0cccd5d4ca
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class WaitingGroupStateTests
+{
+ [Fact]
+ public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ // Both members report a ping well under the default, so the floor is what decides the delay.
+ group.UpdatePing(harness.First, 10);
+ group.UpdatePing(harness.Second, 10);
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetBuffering(harness.First, true);
+ group.SetBuffering(harness.Second, false);
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+
+ var before = DateTime.UtcNow;
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ // DefaultPing is expressed in milliseconds, so the floor must be converted before being
+ // compared against a tick count. Without the conversion the floor is 500 ticks (0.05 ms)
+ // and never applies.
+ var scheduledDelay = group.LastActivity - before;
+ Assert.True(
+ scheduledDelay >= TimeSpan.FromMilliseconds(group.DefaultPing),
+ $"expected a resume delay of at least {group.DefaultPing} ms, got {scheduledDelay.TotalMilliseconds} ms");
+ }
+
+ [Theory]
+ [InlineData(4_000_000_000L)]
+ [InlineData(1_000_000_000_000_000L)]
+ [InlineData(long.MaxValue)]
+ [InlineData(-1L)]
+ public void UpdatePing_ClientReportsAnUnusablePing_IsClampedAndCannotStallTheGroup(long reportedPing)
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.UpdatePing(harness.First, reportedPing);
+
+ Assert.InRange(group.GetHighestPing(), 0, group.MaxPing);
+
+ // The reported ping is scaled into the group's resume point, so an unclamped value either
+ // pushes playback months out or overflows the arithmetic outright.
+ var state = new PlayingGroupState(NullLoggerFactory.Instance);
+ var before = DateTime.UtcNow;
+ state.HandleRequest(
+ new UnpauseGroupRequest(),
+ group,
+ GroupStateType.Paused,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1));
+ }
+
+ private sealed class GroupHarness
+ {
+ public GroupHarness()
+ {
+ var userManager = new Mock<IUserManager>();
+ var sessionManager = new Mock<ISessionManager>();
+ var libraryManager = new Mock<ILibraryManager>();
+
+ var user = new User("tester", "auth-provider", "pwdreset-provider");
+ userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(user);
+
+ var item = new Mock<BaseItem>();
+ item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks;
+ libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object);
+
+ sessionManager
+ .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+
+ sessionManager
+ .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+
+ Group = new SyncPlayGroup(
+ NullLoggerFactory.Instance,
+ userManager.Object,
+ sessionManager.Object,
+ libraryManager.Object);
+
+ First = new SessionInfo(sessionManager.Object, NullLogger.Instance)
+ {
+ Id = "first",
+ UserId = user.Id,
+ UserName = "first"
+ };
+ Second = new SessionInfo(sessionManager.Object, NullLogger.Instance)
+ {
+ Id = "second",
+ UserId = user.Id,
+ UserName = "second"
+ };
+
+ Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None);
+ Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None);
+ Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0);
+ PlaylistItemId = Group.PlayQueue.GetPlayingItemPlaylistId();
+ }
+
+ public SyncPlayGroup Group { get; }
+
+ public SessionInfo First { get; }
+
+ public SessionInfo Second { get; }
+
+ public Guid PlaylistItemId { get; }
+ }
+}
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
index 2de6408cc6..79b9d1e2c5 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
@@ -6,8 +6,11 @@ using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Models.LibraryStructureDto;
using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.v3.Priority;
@@ -26,6 +29,45 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
}
[Fact]
+ [Priority(-3)]
+ public async Task AddVirtualFolder_WithWarmDirectoryServiceCache_InvalidatesTheParentListing()
+ {
+ const string Name = "stale-cache-test";
+
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ var directoryService = _factory.Services.GetRequiredService<IDirectoryService>();
+ var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath;
+
+ // Cache a listing of the libraries root taken before the new folder exists. Everything
+ // resolving through this DirectoryService keeps reading that listing until it is dropped,
+ // so the library stays invisible. Making the caches shared once turned this into a real
+ // test failure, see UpdateLibraryOptions_Valid_Success.
+ Assert.DoesNotContain(
+ directoryService.GetFileSystemEntries(rootFolderPath),
+ x => string.Equals(x.Name, Name, StringComparison.Ordinal));
+
+ var body = new AddVirtualFolderDto()
+ {
+ LibraryOptions = new LibraryOptions()
+ {
+ Enabled = false
+ }
+ };
+
+ using var response = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+
+ Assert.Contains(
+ directoryService.GetFileSystemEntries(rootFolderPath),
+ x => string.Equals(x.Name, Name, StringComparison.Ordinal));
+
+ using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode);
+ }
+
+ [Fact]
[Priority(-1)]
public async Task Post_NewVirtualFolder_NotFound()
{
@@ -114,6 +156,58 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData(".")]
+ [InlineData("test/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task DeleteLibrary_PathTraversal_NotFound(string name)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.DeleteAsync($"Library/VirtualFolders?name={Uri.EscapeDataString(name)}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData(".")]
+ [InlineData("test/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task RenameLibrary_PathTraversalNewName_BadRequest(string newName)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.PostAsync(
+ $"Library/VirtualFolders/Name?name=test&newName={Uri.EscapeDataString(newName)}",
+ null,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task RenameLibrary_PathTraversalName_NotFound(string name)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.PostAsync(
+ $"Library/VirtualFolders/Name?name={Uri.EscapeDataString(name)}&newName=renamed",
+ null,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
[Fact]
[Priority(1)]
public async Task DeleteLibrary_Valid_Success()
diff --git a/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs
new file mode 100644
index 0000000000..3bd8581a5f
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.Migrations;
+using Jellyfin.Server.Migrations.Stages;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+public class CodeMigrationTests
+{
+ [Fact]
+ public async Task Perform_LeavesApplicationSingletonsAlive()
+ {
+ var services = new ServiceCollection()
+ .AddLogging()
+ .RegisterStartupLogger()
+ .AddSingleton<ApplicationSingleton>()
+ .AddTransient<MigrationTransient>();
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var applicationSingleton = serviceProvider.GetRequiredService<ApplicationSingleton>();
+ var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration");
+
+ var migration = new CodeMigration(
+ typeof(TestMigration),
+ new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)),
+ null);
+ await migration.Perform(serviceProvider, logger, CancellationToken.None);
+
+ var performed = TestMigration.Performed;
+ Assert.NotNull(performed);
+ // The migration has to run against the applications own services, and they have to outlive it.
+ Assert.Same(applicationSingleton, performed.Singleton);
+ Assert.False(applicationSingleton.IsDisposed);
+ Assert.Same(applicationSingleton, serviceProvider.GetRequiredService<ApplicationSingleton>());
+ // Services created for the migration itself are still owned by the migration.
+ Assert.True(performed.Transient.IsDisposed);
+ // The startup logger has to stay attached to the topic of the running migration.
+ Assert.Same(logger.Topic, performed.Logger.Topic);
+ }
+
+ [Fact]
+ public async Task Perform_DoesNotLeakTheMigrationTopic()
+ {
+ var services = new ServiceCollection()
+ .AddLogging()
+ .RegisterStartupLogger()
+ .AddSingleton<ApplicationSingleton>()
+ .AddTransient<MigrationTransient>();
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration");
+
+ var migration = new CodeMigration(
+ typeof(TestMigration),
+ new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)),
+ null);
+ await migration.Perform(serviceProvider, logger, CancellationToken.None);
+
+ // The topic belongs to the migration that ran, so loggers resolved afterwards must not still write into it.
+ Assert.Null(serviceProvider.GetRequiredService<IStartupLogger<CodeMigrationTests>>().Topic);
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ private sealed class ApplicationSingleton : IDisposable
+ {
+ public bool IsDisposed { get; private set; }
+
+ public void Dispose()
+ {
+ IsDisposed = true;
+ }
+ }
+
+ private sealed class MigrationTransient : IDisposable
+ {
+ public bool IsDisposed { get; private set; }
+
+ public void Dispose()
+ {
+ IsDisposed = true;
+ }
+ }
+
+ private sealed class TestMigration : IAsyncMigrationRoutine
+ {
+ public TestMigration(ApplicationSingleton singleton, MigrationTransient transient, IStartupLogger<TestMigration> logger)
+ {
+ Singleton = singleton;
+ Transient = transient;
+ Logger = logger;
+ }
+
+ public static TestMigration? Performed { get; private set; }
+
+ public ApplicationSingleton Singleton { get; }
+
+ public MigrationTransient Transient { get; }
+
+ public IStartupLogger<TestMigration> Logger { get; }
+
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ Performed = this;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs
new file mode 100644
index 0000000000..c2894e9647
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs
@@ -0,0 +1,54 @@
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.ServerSetupApp;
+
+public class StartupLoggerTests
+{
+ [Fact]
+ public void BeginAmbientTopic_AttachesNewLoggersToTheTopic()
+ {
+ var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration");
+
+ using (StartupLogger.BeginAmbientTopic(migration.Topic))
+ {
+ Assert.Same(migration.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+ }
+
+ [Fact]
+ public void BeginAmbientTopic_RestoresThePreviousTopic()
+ {
+ var root = new StartupLogger(NullLogger.Instance);
+ var outer = root.BeginGroup($"Outer");
+ var inner = outer.BeginGroup($"Inner");
+
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+
+ using (StartupLogger.BeginAmbientTopic(outer.Topic))
+ {
+ using (StartupLogger.BeginAmbientTopic(inner.Topic))
+ {
+ Assert.Same(inner.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ // Leaving a nested topic has to fall back to the enclosing one, not to the setup UI root.
+ Assert.Same(outer.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ [Fact]
+ public void BeginGroup_KeepsAnExplicitTopicOverTheAmbientOne()
+ {
+ var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration");
+ var unrelated = new StartupLogger(NullLogger.Instance).BeginGroup($"Unrelated");
+
+ using (StartupLogger.BeginAmbientTopic(migration.Topic))
+ {
+ Assert.Same(unrelated.Topic, unrelated.With(NullLogger.Instance).Topic);
+ }
+ }
+}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
index a04b37f215..3767b5c954 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
@@ -124,6 +124,27 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
}
[Fact]
+ public void Fetch_Valid_MultiEpisode_Unordered_Success()
+ {
+ var result = new MetadataResult<Episode>()
+ {
+ Item = new Episode()
+ };
+
+ _parser.Fetch(result, "Test Data/Rising-Reversed.nfo", CancellationToken.None);
+
+ var item = result.Item;
+ // The episodedetails blocks are stored in descending order, the merged episode must still be in ascending order
+ Assert.Equal("Rising (1) / Rising (2)", item.Name);
+ Assert.Equal(1, item.IndexNumber);
+ Assert.Equal(2, item.IndexNumberEnd);
+ Assert.Equal(1, item.ParentIndexNumber);
+ Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy. / Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.", item.Overview);
+ Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate);
+ Assert.Equal(2004, item.ProductionYear);
+ }
+
+ [Fact]
public void Fetch_Valid_MultiEpisode_With_Missing_Tags_Success()
{
var result = new MetadataResult<Episode>()
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo
new file mode 100644
index 0000000000..6dbab13566
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo
@@ -0,0 +1,43 @@
+<episodedetails>
+ <title>Rising (2)</title>
+ <season>1</season>
+ <episode>2</episode>
+ <aired>2004-07-16</aired>
+ <plot>Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.</plot>
+ <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25334.jpg</thumb>
+ <watched>false</watched>
+ <rating>7.9</rating>
+ <actor>
+ <name>Joe Flanigan</name>
+ <role>John Sheppard</role>
+ <order>0</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+ </actor>
+ <actor>
+ <name>David Hewlett</name>
+ <role>Rodney McKay</role>
+ <order>1</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+ </actor>
+</episodedetails><episodedetails>
+ <title>Rising (1)</title>
+ <season>1</season>
+ <episode>1</episode>
+ <aired>2004-07-16</aired>
+ <plot>A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.</plot>
+ <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25333.jpg</thumb>
+ <watched>false</watched>
+ <rating>8.0</rating>
+ <actor>
+ <name>Joe Flanigan</name>
+ <role>John Sheppard</role>
+ <order>0</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+ </actor>
+ <actor>
+ <name>David Hewlett</name>
+ <role>Rodney McKay</role>
+ <order>1</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+ </actor>
+</episodedetails>