aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci-codeql-analysis.yml6
-rw-r--r--CONTRIBUTORS.md1
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs113
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs31
-rw-r--r--MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs25
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs12
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs28
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs11
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs2
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs31
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json6
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs156
15 files changed, 391 insertions, 38 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index a55a400008..ca7213a690 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
+ uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
+ uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
+ uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 4e323e332a..0df74c2bc3 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -233,6 +233,7 @@
- [MSalman5230](https://github.com/MSalman5230)
- [dwandw](https://github.com/dwandw)
- [Lampan-git](https://github.com/Lampan-git)
+ - [rwebster85](https://github.com/rwebster85)
# Emby Contributors
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 828bdd6859..5d62332552 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -641,8 +641,7 @@ namespace Emby.Server.Implementations.Session
if (playingSessions.Count > 0)
{
var idle = playingSessions
- .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5)
- .ToList();
+ .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5);
foreach (var session in idle)
{
diff --git a/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
new file mode 100644
index 0000000000..e9eefc20dc
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Extensions;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to recompute the SortName of all items that have a forced sort name.
+/// </summary>
+[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class RefreshForcedSortNames : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger<RefreshForcedSortNames> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IServerConfigurationManager _configurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
+ /// <param name="configurationManager">The server configuration manager providing the sort rules.</param>
+ public RefreshForcedSortNames(
+ IStartupLogger<RefreshForcedSortNames> logger,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IServerConfigurationManager configurationManager)
+ {
+ _logger = logger;
+ _dbProvider = dbProvider;
+ _configurationManager = configurationManager;
+ }
+
+ /// <inheritdoc />
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ const int Limit = 10000;
+ int itemCount = 0;
+
+ var configuration = _configurationManager.Configuration;
+ // Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules.
+ var personType = typeof(Person).ToString();
+
+ var sw = Stopwatch.StartNew();
+
+ using var context = _dbProvider.CreateDbContext();
+ var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName));
+ _logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records);
+
+ var processedInPartition = 0;
+
+ await foreach (var item in context.BaseItems
+ .Where(b => !string.IsNullOrEmpty(b.ForcedSortName))
+ .OrderBy(e => e.Id)
+ .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed))
+ .PartitionEagerAsync(Limit, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ try
+ {
+ var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal);
+ var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration);
+ if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal))
+ {
+ _logger.LogDebug(
+ "Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'",
+ item.Id,
+ item.SortName,
+ newSortName);
+ item.SortName = newSortName;
+ itemCount++;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name);
+ }
+
+ processedInPartition++;
+
+ if (processedInPartition >= Limit)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ // Clear tracked entities to avoid memory growth across partitions
+ context.ChangeTracker.Clear();
+ processedInPartition = 0;
+ }
+ }
+
+ // Save any remaining changes after the loop
+ if (processedInPartition > 0)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ context.ChangeTracker.Clear();
+ }
+
+ _logger.LogInformation(
+ "Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}",
+ itemCount,
+ records,
+ sw.Elapsed);
+ }
+}
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 49a4ed4bf6..21a726aaec 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -540,8 +541,8 @@ namespace MediaBrowser.Controller.Entities
{
if (!string.IsNullOrEmpty(ForcedSortName))
{
- // Need the ToLower because that's what CreateSortName does
- _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant();
+ // Run the forced sort name through the same cleaning as auto-generated sort names.
+ _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
else
{
@@ -926,19 +927,31 @@ namespace MediaBrowser.Controller.Entities
/// <returns>System.String.</returns>
protected virtual string CreateSortName()
{
- if (Name is null)
+ return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
+ }
+
+ /// <summary>
+ /// Cleans a raw name into its sortable form by applying the configured sort rules.
+ /// </summary>
+ /// <param name="name">The raw name to clean.</param>
+ /// <param name="enableAlphaNumericSorting">Whether alphanumeric sorting rules should be applied.</param>
+ /// <param name="configuration">The server configuration providing the sort rules.</param>
+ /// <returns>The cleaned, sortable name, or <c>null</c> if <paramref name="name"/> is <c>null</c>.</returns>
+ public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration)
+ {
+ if (name is null)
{
return null; // some items may not have name filled in properly
}
- if (!EnableAlphaNumericSorting)
+ if (!enableAlphaNumericSorting)
{
- return Name.TrimStart();
+ return name.TrimStart();
}
- var sortable = Name.Trim().ToLowerInvariant();
+ var sortable = name.Trim().ToLowerInvariant();
- foreach (var search in ConfigurationManager.Configuration.SortRemoveWords)
+ foreach (var search in configuration.SortRemoveWords)
{
// Remove from beginning if a space follows
if (sortable.StartsWith(search + " ", StringComparison.Ordinal))
@@ -956,12 +969,12 @@ namespace MediaBrowser.Controller.Entities
}
}
- foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters)
+ foreach (var removeChar in configuration.SortRemoveCharacters)
{
sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
}
- foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters)
+ foreach (var replaceChar in configuration.SortReplaceCharacters)
{
sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
}
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
index c0a168192e..9326864d78 100644
--- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -272,7 +272,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
public void SetPlayingItemByIndex(int playlistIndex)
{
var playlist = GetPlaylistInternal();
- if (playlistIndex < 0 || playlistIndex > playlist.Count)
+ if (playlistIndex < 0 || playlistIndex >= playlist.Count)
{
PlayingItemIndex = NoPlayingItemIndex;
}
@@ -293,6 +293,15 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
{
var playingItem = GetPlayingItem();
+ // Removed items that precede the playing item shift its index as well.
+ var removedBeforePlayingItem = 0;
+ if (playingItem is not null)
+ {
+ removedBeforePlayingItem = GetPlaylistInternal()
+ .Take(PlayingItemIndex)
+ .Count(item => playlistItemIds.Contains(item.PlaylistItemId));
+ }
+
_sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
_shuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
@@ -303,12 +312,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
if (playlistItemIds.Contains(playingItem.PlaylistItemId))
{
// Playing item has been removed, picking previous item.
- PlayingItemIndex--;
+ PlayingItemIndex -= removedBeforePlayingItem + 1;
if (PlayingItemIndex < 0)
{
// Was first element, picking next if available.
// Default to no playing item otherwise.
- PlayingItemIndex = _sortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex;
+ PlayingItemIndex = GetPlaylistInternal().Count > 0 ? 0 : NoPlayingItemIndex;
}
return true;
@@ -444,6 +453,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
public bool Next()
{
+ if (GetPlaylistInternal().Count == 0)
+ {
+ return false;
+ }
+
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
@@ -474,6 +488,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
public bool Previous()
{
+ if (GetPlaylistInternal().Count == 0)
+ {
+ return false;
+ }
+
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index 1f84b46a2b..91d0c3d5a6 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -8,6 +8,7 @@ using System.Linq;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
+using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -662,8 +663,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
writer.Write(testKey);
}
- using var reader = readStdErr ? process.StandardError : process.StandardOutput;
- return reader.ReadToEnd();
+ // Drain both streams concurrently to prevent pipe hanging, see #17429
+ using var standardOutput = process.StandardOutput;
+ using var standardError = process.StandardError;
+ var standardOutputTask = standardOutput.ReadToEndAsync();
+ var standardErrorTask = standardError.ReadToEndAsync();
+ process.WaitForExit();
+ Task.WaitAll(standardOutputTask, standardErrorTask);
+
+ return (readStdErr ? standardErrorTask : standardOutputTask).GetAwaiter().GetResult();
}
}
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 989701350c..b6acfdbf3b 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -757,11 +757,17 @@ namespace MediaBrowser.MediaEncoding.Probing
if (string.IsNullOrEmpty(stream.Title))
{
- // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SoundHandler"
- string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
- if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase))
+ // FFprobe exposes MP4 track names via the name tag rather than title
+ stream.Title = GetDictionaryValue(streamInfo.Tags, "name");
+
+ if (string.IsNullOrEmpty(stream.Title))
{
- stream.Title = handlerName;
+ // fall back to handler_name if populated and not the default "SoundHandler"
+ string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
+ if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase))
+ {
+ stream.Title = handlerName;
+ }
}
}
}
@@ -781,11 +787,17 @@ namespace MediaBrowser.MediaEncoding.Probing
if (string.IsNullOrEmpty(stream.Title))
{
- // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler"
- string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
- if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase))
+ // FFprobe exposes MP4 track names via the name tag rather than title
+ stream.Title = GetDictionaryValue(streamInfo.Tags, "name");
+
+ if (string.IsNullOrEmpty(stream.Title))
{
- stream.Title = handlerName;
+ // fall back to handler_name if populated and not the default "SubtitleHandler"
+ string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
+ if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase))
+ {
+ stream.Title = handlerName;
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
index 787d2ad878..2bd2676ceb 100644
--- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
@@ -42,7 +42,7 @@ public class ComicBookInfoProvider : IComicProvider
if (path is null)
{
- _logger.LogError("could not load comic: {Path}", info.Path);
+ _logger.LogDebug("could not load comic: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
index 02cc02b7f3..cfd22a850e 100644
--- a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
@@ -38,7 +38,7 @@ public class ExternalComicInfoProvider : IComicProvider
if (comicInfoXml is null)
{
- _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path);
+ _logger.LogDebug("No external ComicInfo metadata found for {Path}.", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
@@ -67,23 +67,22 @@ public class ExternalComicInfoProvider : IComicProvider
private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken)
{
- var path = GetXmlFilePath(info.Path).FullName;
-
- if (path is null)
+ var file = GetXmlFilePath(info.Path);
+ if (!file.Exists)
{
return null;
}
try
{
- using var reader = XmlReader.Create(path, new XmlReaderSettings { Async = true });
+ using var reader = XmlReader.Create(file.FullName, new XmlReaderSettings { Async = true });
var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken);
return await comicInfoXml.ConfigureAwait(false);
}
catch (Exception e)
{
- _logger.LogInformation(e, "Could not load external XML from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path);
+ _logger.LogWarning(e, "Could not load external ComicInfo XML from {Path}.", file.FullName);
return null;
}
}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
index 98a6aba7d6..19062452b9 100644
--- a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
@@ -36,7 +36,7 @@ public class InternalComicInfoProvider : IComicProvider
if (comicInfoXml is null)
{
- _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
+ _logger.LogDebug("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index c0a2b0ecca..de109c8d65 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -4,10 +4,12 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
@@ -29,6 +31,35 @@ public class BaseItemTests
=> Assert.Equal(expected, BaseItem.ModifySortChunks(input));
[Theory]
+ [InlineData("The Matrix", "matrix")]
+ [InlineData("Spider-Man", "spiderman")]
+ [InlineData("A Movie: Part 2", "movie: part 0000000002")]
+ public void GetSortName_AppliesConfiguredCleaning(string input, string expected)
+ => Assert.Equal(expected, BaseItem.GetSortName(input, true, new ServerConfiguration()));
+
+ [Fact]
+ public void GetSortName_WithoutAlphaNumericSorting_ReturnsTrimmedInput()
+ => Assert.Equal("The Matrix", BaseItem.GetSortName(" The Matrix", false, new ServerConfiguration()));
+
+ [Fact]
+ public void SortName_ForcedSortName_IsCleanedLikeAutoSortName()
+ {
+ var configManager = new Mock<IServerConfigurationManager>();
+ configManager.Setup(x => x.Configuration).Returns(new ServerConfiguration());
+ BaseItem.ConfigurationManager = configManager.Object;
+
+ const string Raw = "The Spider-Man: Homecoming";
+
+ var auto = new Video { Name = Raw };
+ var forced = new Video { Name = "zzz unrelated name", ForcedSortName = Raw };
+
+ // A forced sort name must be cleaned the same way as an auto-generated one so both sort together (#17388).
+ Assert.Equal(auto.SortName, forced.SortName);
+ // Sanity: cleaning actually ran (leading article and hyphen removed, colon kept, lowercased).
+ Assert.Equal("spiderman: homecoming", forced.SortName);
+ }
+
+ [Theory]
[InlineData("/Movies/Ted/Ted.mp4", "/Movies/Ted/Ted - Unrated Edition.mp4", "Ted", "Unrated Edition")]
[InlineData("/Movies/Deadpool 2 (2018)/Deadpool 2 (2018).mkv", "/Movies/Deadpool 2 (2018)/Deadpool 2 (2018) - Super Duper Cut.mkv", "Deadpool 2 (2018)", "Super Duper Cut")]
public void GetMediaSourceName_Valid(string primaryPath, string altPath, string name, string altName)
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index b723fc7208..52e0b19700 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -219,7 +219,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal("eng", res.MediaStreams[4].Language);
Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[4].Type);
Assert.Equal("mov_text", res.MediaStreams[4].Codec);
- Assert.Null(res.MediaStreams[4].Title);
+ Assert.Equal("SDH", res.MediaStreams[4].Title);
Assert.True(res.MediaStreams[4].IsHearingImpaired);
Assert.Equal("eng", res.MediaStreams[5].Language);
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json
index 9a7a4ba373..e406cc18b0 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json
@@ -95,7 +95,8 @@
"tags": {
"creation_time": "2021-09-13T22:42:42.000000Z",
"language": "eng",
- "handler_name": "Surround 6.1",
+ "handler_name": "SoundHandler",
+ "name": "Surround 6.1",
"vendor_id": "[0][0][0][0]"
}
},
@@ -215,7 +216,8 @@
"tags": {
"creation_time": "2021-09-13T22:42:42.000000Z",
"language": "eng",
- "handler_name": "SubtitleHandler"
+ "handler_name": "SubtitleHandler",
+ "name": "SDH"
}
},
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
new file mode 100644
index 0000000000..32685556b2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Model.SyncPlay;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class PlayQueueManagerTests
+{
+ private static PlayQueueManager CreateQueue(int itemCount)
+ {
+ var items = Enumerable.Range(0, itemCount).Select(_ => Guid.NewGuid()).ToList();
+ var queue = new PlayQueueManager();
+ queue.SetPlaylist(items);
+ return queue;
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemAndPrecedingItemRemoved_PicksPreviousItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetPlayingItemByIndex(3);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Equal(3, queue.GetPlaylist().Count);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemAndAllPrecedingItemsRemoved_PicksFirstRemainingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(2);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[1].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[2].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Single(queue.GetPlaylist());
+ Assert.Equal(0, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_AllItemsRemoved_ResetsPlayingItem()
+ {
+ var queue = CreateQueue(2);
+ queue.SetPlayingItemByIndex(1);
+
+ var toRemove = queue.GetPlaylist().Select(item => item.PlaylistItemId).ToList();
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Empty(queue.GetPlaylist());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_ShuffleMode_PicksPreviousItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetShuffleMode(GroupShuffleMode.Shuffle);
+ queue.SetPlayingItemByIndex(3);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Equal(3, queue.GetPlaylist().Count);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemNotRemoved_RestoresPlayingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(2);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.False(playingItemRemoved);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Theory]
+ [InlineData(GroupRepeatMode.RepeatNone)]
+ [InlineData(GroupRepeatMode.RepeatOne)]
+ [InlineData(GroupRepeatMode.RepeatAll)]
+ public void Next_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode)
+ {
+ var queue = new PlayQueueManager();
+ queue.SetRepeatMode(repeatMode);
+
+ Assert.False(queue.Next());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Theory]
+ [InlineData(GroupRepeatMode.RepeatNone)]
+ [InlineData(GroupRepeatMode.RepeatOne)]
+ [InlineData(GroupRepeatMode.RepeatAll)]
+ public void Previous_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode)
+ {
+ var queue = new PlayQueueManager();
+ queue.SetRepeatMode(repeatMode);
+
+ Assert.False(queue.Previous());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ public void SetPlayingItemByIndex_OutOfBounds_ResetsPlayingItem(int playlistIndex)
+ {
+ var queue = CreateQueue(2);
+
+ queue.SetPlayingItemByIndex(playlistIndex);
+
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Fact]
+ public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
+ {
+ var queue = CreateQueue(2);
+ var expectedItemId = queue.GetPlaylist()[1].ItemId;
+
+ queue.SetPlayingItemByIndex(1);
+
+ Assert.True(queue.IsItemPlaying());
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+}