diff options
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()); + } +} |
