From b188071931a15ec4db8eb96fe1e24d98f4e48068 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:15:53 -0400 Subject: Backport pull request #17898 from jellyfin/release-12.z Populate missing tags array in Filters2 Original-merge: 3d52fbe84e78d6447465bb831039dabb6142ca67 Merged-by: crobibero Backported-by: Cody Robibero --- Emby.Server.Implementations/Library/LibraryManager.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'Emby.Server.Implementations/Library/LibraryManager.cs') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 0044fcd4dc..f97cc7a0a0 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -4127,6 +4127,18 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetQueryFiltersLegacy(query); } + /// + public IReadOnlyList GetTagNames(InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetTagNames(query); + } + /// public IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType) { -- cgit v1.2.3 From 28be079779d4508b5ab2a2fd4b482f3fd929ced5 Mon Sep 17 00:00:00 2001 From: orut34iop Date: Tue, 15 Sep 2026 11:16:56 -0400 Subject: Backport pull request #18006 from jellyfin/release-12.z Preserve active scans when requesting a background library refresh Original-merge: 949e0585a65a91f90644a2475c1e1009e5d51e90 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 19 ++++---- .../Controllers/LibraryStructureController.cs | 6 +-- .../Recordings/RecordingsManager.cs | 2 +- .../Library/LibraryManagerScanTests.cs | 55 ++++++++++++++++++++++ 4 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs (limited to 'Emby.Server.Implementations/Library/LibraryManager.cs') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index f97cc7a0a0..2b3ccd5c99 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -318,7 +318,7 @@ namespace Emby.Server.Implementations.Library if (wizardChanged) { - _taskManager.CancelIfRunningAndQueue(); + QueueLibraryScan(); } } @@ -3799,7 +3799,7 @@ namespace Emby.Server.Implementations.Library if (refreshLibrary) { - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -3867,13 +3867,16 @@ namespace Emby.Server.Implementations.Library } } - private void StartScanInBackground() + internal Task StartScanInBackground() { - Task.Run(() => + // An active scan already handles library structure changes, so this request can be dropped. + if (IsScanRunning) { - // No need to start if scanning the library because it will handle it - ValidateMediaLibrary(new Progress(), CancellationToken.None); - }); + return Task.CompletedTask; + } + + // Queue instead of restarting so a scan that starts after the check is allowed to finish. + return Task.Run(QueueLibraryScan); } public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath) @@ -3984,7 +3987,7 @@ namespace Emby.Server.Implementations.Library { await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - StartScanInBackground(); + _ = StartScanInBackground(); } else { diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 65bfe25d21..7f63e410a5 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -213,7 +213,7 @@ public class LibraryStructureController : BaseJellyfinApiController { _libraryManager.ClearIgnoreRuleCache(); // We don't know if this one can be validated individually, trigger a new validation - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } _libraryManager.ClearIgnoreRuleCache(); @@ -260,7 +260,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } else { @@ -327,7 +327,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } else { diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs index 62a06370da..8a5aeccba5 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -286,7 +286,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable if (requiresRefresh) { - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs new file mode 100644 index 0000000000..6d0c491382 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.ScheduledTasks.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Tasks; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class LibraryManagerScanTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StartScanInBackground_QueuesOnlyWhenIdle(bool scanRunning) + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + var tasks = fixture.Freeze>(); + var manager = fixture.Create(); + typeof(ServerLibraryManager).GetProperty(nameof(ServerLibraryManager.IsScanRunning))!.SetValue(manager, scanRunning); + + await manager.StartScanInBackground().ConfigureAwait(true); + + tasks.Verify(t => t.QueueScheduledTask(), scanRunning ? Times.Never() : Times.Once()); + tasks.Verify(t => t.CancelIfRunningAndQueue(), Times.Never()); + } + + [Fact] + public async Task ValidateMediaLibrary_RestartsScheduledScan() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + var tasks = fixture.Freeze>(); + var manager = fixture.Create(); + + await manager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(true); + + tasks.Verify(t => t.CancelIfRunningAndQueue(), Times.Once()); + tasks.Verify(t => t.QueueScheduledTask(), Times.Never()); + } +} -- cgit v1.2.3 From cdd2b3e0479343aaf33d21e6356412377b534b2c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:17:07 -0400 Subject: Backport pull request #18030 from jellyfin/release-12.z Stop wrong-type alternate version cleanup from recursing Original-merge: af368af0619ab04594169468790a6b61f55543b1 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 13 +- .../LibraryManager/ResolveAlternateVersionTests.cs | 145 +++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs (limited to 'Emby.Server.Implementations/Library/LibraryManager.cs') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2b3ccd5c99..caba304888 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -878,7 +878,18 @@ namespace Emby.Server.Implementations.Library wrongTypeItem.GetType().Name, expectedVideoType.Name, path); - DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false }); + + // A full DeleteItem would save the primary version, which resolves its + // alternates again and re-enters here before this row is gone. + DeleteItemsUnsafeFast([wrongTypeItem]); + + // The fast path skips the parent bookkeeping, and the stale item is listed + // under its ParentId, so that folder's cached listing has to be dropped. + if (wrongTypeItem.GetParent() is Folder staleParent) + { + staleParent.Children = null; + staleParent.UserData = null; + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs new file mode 100644 index 0000000000..31109b2968 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager; + +public sealed class ResolveAlternateVersionTests : IDisposable +{ + private const string PrimaryPath = "/movies/Up/Up.mkv"; + private const string AlternatePath = "/movies/Up/Up - 1080p.mkv"; + + private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; + private readonly Mock _persistenceServiceMock; + private readonly Folder _staleParent; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IMediaSourceManager? _previousMediaSourceManager; + private readonly IItemRepository? _previousItemRepository; + + public ResolveAlternateVersionTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + fixture.Freeze>() + .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + _persistenceServiceMock = fixture.Freeze>(); + var itemRepositoryMock = fixture.Freeze>(); + fixture.Freeze>() + .Setup(f => f.GetFileInfo(It.IsAny())) + .Returns(path => new FileSystemMetadata { FullName = path }); + + _libraryManager = fixture.Build() + .Do(s => s.AddParts( + fixture.Create>(), + [], + fixture.Create>(), + fixture.Create>(), + fixture.Create>())) + .Create(); + + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousMediaSourceManager = BaseItem.MediaSourceManager; + _previousItemRepository = BaseItem.ItemRepository; + BaseItem.LibraryManager = _libraryManager; + + var mediaSourceManagerMock = new Mock(); + mediaSourceManagerMock.Setup(m => m.GetMediaStreams(It.IsAny())).Returns([]); + mediaSourceManagerMock.Setup(m => m.GetMediaAttachments(It.IsAny())).Returns([]); + BaseItem.MediaSourceManager = mediaSourceManagerMock.Object; + + // A reloaded listing comes back empty, so a stale entry surviving is visible. + itemRepositoryMock.Setup(i => i.GetItemList(It.IsAny())).Returns([]); + BaseItem.ItemRepository = itemRepositoryMock.Object; + + BaseItem.FileSystem ??= fixture.Create(); + BaseItem.MediaSegmentManager ??= fixture.Create(); + BaseItem.ConfigurationManager ??= fixture.Create(); + Video.RecordingsManager ??= fixture.Create(); + + var primary = new Movie + { + Name = "Up", + Path = PrimaryPath, + LocalAlternateVersions = [AlternatePath], + Id = _libraryManager.GetNewItemId(PrimaryPath, typeof(Movie)) + }; + + _staleParent = new Folder + { + Name = "Up", + Path = "/movies/Up", + Id = _libraryManager.GetNewItemId("/movies/Up", typeof(Folder)) + }; + + var staleAlternate = new Video + { + Name = "Up - 1080p", + Path = AlternatePath, + OwnerId = primary.Id, + ParentId = _staleParent.Id, + Id = _libraryManager.GetNewItemId(AlternatePath, typeof(Video)) + }; + staleAlternate.SetPrimaryVersionId(primary.Id); + + itemRepositoryMock + .Setup(i => i.RetrieveItem(It.IsAny())) + .Returns(id => id.Equals(primary.Id) ? primary + : id.Equals(staleAlternate.Id) ? staleAlternate + : id.Equals(_staleParent.Id) ? _staleParent + : null!); + + StaleAlternateId = staleAlternate.Id; + } + + private Guid StaleAlternateId { get; } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.MediaSourceManager = _previousMediaSourceManager!; + BaseItem.ItemRepository = _previousItemRepository!; + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsRowWithoutResavingPrimary() + { + // The alternate is stored under the id of the generic Video type while its primary is a Movie. + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + _persistenceServiceMock.Verify( + p => p.DeleteItem(It.Is>(ids => ids.Count == 1 && ids[0].Equals(StaleAlternateId))), + Times.Once); + + // Saving the primary is what re-enters this method before the stale row is gone. + _persistenceServiceMock.Verify( + p => p.SaveItems(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsCachedParentListing() + { + _staleParent.Children = [new Video { Name = "Up - 1080p", Path = AlternatePath }]; + + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + Assert.Empty(_staleParent.Children); + } +} -- cgit v1.2.3 From 64befbcdaa2718468fc015cc8224c8d71ddd001c Mon Sep 17 00:00:00 2001 From: orut34iop Date: Sun, 27 Sep 2026 16:30:32 -0400 Subject: Backport pull request #18005 from jellyfin/release-12.z Match artist creation lookups using CleanName Original-merge: d341176eef63e38839f648b00a358240279ec363 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 1 - .../Item/BaseItemRepository.TranslateQuery.cs | 12 +- .../Entities/InternalItemsQuery.cs | 2 - .../Item/ArtistNameLookupTests.cs | 173 +++++++++++++++++++++ 4 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs (limited to 'Emby.Server.Implementations/Library/LibraryManager.cs') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index caba304888..4c468569bc 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1354,7 +1354,6 @@ namespace Emby.Server.Implementations.Library { IncludeItemTypes = [BaseItemKind.MusicArtist], Name = name, - UseRawName = true, DtoOptions = options }).Cast() .OrderBy(i => i.IsAccessedByName ? 1 : 0) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index d726f0f143..a7110485b4 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -476,16 +476,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.Name)) { - if (filter.UseRawName == true) - { - var nameLower = filter.Name.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower); - } - else - { - var cleanName = filter.Name.GetCleanValue(); - baseQuery = baseQuery.Where(e => e.CleanName == cleanName); - } + var cleanName = filter.Name.GetCleanValue(); + baseQuery = baseQuery.Where(e => e.CleanName == cleanName); } var nameContains = filter.NameContains; diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 7c88d5dd05..e0f0e0d476 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -235,8 +235,6 @@ namespace MediaBrowser.Controller.Entities public string? Name { get; set; } - public bool? UseRawName { get; set; } - public string? Person { get; set; } public Guid[] PersonIds { get; set; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs new file mode 100644 index 0000000000..0c1d453293 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ArtistNameLookupTests : SqliteDbTestFixture +{ + private readonly CommandRecorder _recorder; + + public ArtistNameLookupTests() + : this(new CommandRecorder()) + { + } + + private ArtistNameLookupTests(CommandRecorder recorder) + : base(recorder) + { + _recorder = recorder; + } + + [Theory] + [InlineData("Björk", "bjork")] + [InlineData("AC/DC", "ac dc")] + [InlineData("An Artist", " AN ARTIST ")] + [InlineData("Artist", "ARTIST")] + public void GetArtist_UsesSameNormalizedNameAsFindArtists(string storedName, string requestedName) + { + var lookup = new ItemTypeLookup(); + var artistId = Guid.NewGuid(); + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + new BaseItemEntity + { + Id = artistId, + Name = storedName, + CleanName = storedName.GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }, + new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = storedName, + CleanName = storedName.GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.Person] + }); + context.SaveChanges(); + } + + var manager = CreateLibraryManager(lookup); + _recorder.Commands.Clear(); + + Assert.Equal(artistId, manager.GetArtist(requestedName).Id); + var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("\"CleanName\" =", StringComparison.Ordinal)); + Assert.Contains(Explain(query), line => line.Contains("IX_BaseItems_Type_CleanName (Type=? AND CleanName=?)", StringComparison.Ordinal)); + Assert.Equal(artistId, Assert.Single(manager.GetArtists([requestedName])[requestedName]).Id); + } + + [Fact] + public void GetArtist_PrefersFilesystemArtistWhenNormalizedNamesMatch() + { + var lookup = new ItemTypeLookup(); + var parentId = Guid.NewGuid(); + var artistId = Guid.NewGuid(); + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + new BaseItemEntity + { + Id = parentId, + Name = "Music", + Type = lookup.BaseItemKindNames[BaseItemKind.Folder] + }, + new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = "Bjork", + CleanName = "Bjork".GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }, + new BaseItemEntity + { + Id = artistId, + ParentId = parentId, + Name = "Björk", + CleanName = "Björk".GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }); + context.SaveChanges(); + } + + Assert.Equal(artistId, CreateLibraryManager(lookup).GetArtist("Bjork").Id); + } + + private ServerLibraryManager CreateLibraryManager(ItemTypeLookup lookup) + { + var repository = CreateBaseItemRepository(lookup); + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + fixture.Inject(repository); + fixture.Inject(new LinkedChildrenService(CreateDbContextFactory(), lookup, repository)); + return fixture.Create(); + } + + private string[] Explain(RecordedCommand query) + { + using var context = CreateDbContext(); + using var command = context.Database.GetDbConnection().CreateCommand(); +#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters. + command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql; +#pragma warning restore CA2100 + foreach (var value in query.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = value.Name; + parameter.Value = value.Value; + command.Parameters.Add(parameter); + } + + using var reader = command.ExecuteReader(); + var plan = new List(); + while (reader.Read()) + { + plan.Add(reader.GetString(3)); + } + + return plan.ToArray(); + } + + private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters); + + private sealed class CommandRecorder : DbCommandInterceptor + { + public List Commands { get; } = []; + + public override InterceptionResult ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Record(command); + return result; + } + + public override InterceptionResult NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Record(command); + return result; + } + + private void Record(DbCommand command) => Commands.Add(new RecordedCommand( + command.CommandText, + command.Parameters.Cast().Select(p => (p.ParameterName, p.Value)).ToArray())); + } +} -- cgit v1.2.3 From 0d1483e2ddb43faf7904ef09fb022a4100397260 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 27 Sep 2026 16:30:50 -0400 Subject: Backport pull request #18139 from jellyfin/release-12.z Only regenerate extra names that were generated by the scan Original-merge: 25d97ac5d0c7b71a8d103b6a23fc2b487d4a2a47 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 49 ++++++++++++++-- .../Library/LibraryManager/FindExtrasTests.cs | 68 +++++++++++++++++++++- 2 files changed, 110 insertions(+), 7 deletions(-) (limited to 'Emby.Server.Implementations/Library/LibraryManager.cs') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 4c468569bc..6f3df084c0 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3466,6 +3466,7 @@ namespace Emby.Server.Implementations.Library var extras = new List(); var typeCounters = new Dictionary(); + var generatedNames = new Dictionary>(); // Order by path so that the numbering handed out below does not depend on the // order the file system happened to list the folder in @@ -3502,10 +3503,12 @@ namespace Emby.Server.Implementations.Library extra = itemById; } - // An extra is named after its file, so the file is the source of truth. Items created - // by older versions, or renamed by a metadata provider, are corrected here; - // RefreshExtras persists the change. - if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true) + // The name derived from the file is only a default. A name that came from anywhere else, + // such as a local metadata file, is the user's and has to survive the scan, so only a + // name this method handed out itself is renewed; RefreshExtras persists the change. + if (!string.IsNullOrEmpty(name) + && extra.LockedFields?.Contains(MetadataField.Name) != true + && (itemById is null || IsGeneratedExtraName(extra.Name, candidate))) { extra.Name = name; } @@ -3527,6 +3530,31 @@ namespace Emby.Server.Implementations.Library return null; } + + bool IsGeneratedExtraName(string currentName, ExtraCandidate candidate) + { + // The file name is what an extra was called before it was given a name of its type + if (string.Equals(currentName, candidate.Extra.Name, StringComparison.Ordinal)) + { + return true; + } + + if (!generatedNames.TryGetValue(candidate.ExtraType, out var names)) + { + // Any of the numbers of this type may have been handed out, as the order the extras + // of a type are numbered in shifts as files appear beside them or are taken away + names = new HashSet(StringComparer.Ordinal); + var count = candidates.Count(c => c.ExtraType == candidate.ExtraType); + for (var seen = 0; seen < count; seen++) + { + names.Add(GetNumberedExtraName(candidate.ExtraType, seen)); + } + + generatedNames[candidate.ExtraType] = names; + } + + return names.Contains(currentName); + } } /// @@ -3553,7 +3581,18 @@ namespace Emby.Server.Implementations.Library typeCounters.TryGetValue(candidate.ExtraType, out var seen); typeCounters[candidate.ExtraType] = seen + 1; - var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType)); + return GetNumberedExtraName(candidate.ExtraType, seen); + } + + /// + /// Gets the name given to the n-th extra of a type that is named after its type. + /// + /// The extra type. + /// Number of extras of the type named before this one. + /// The name. + private string GetNumberedExtraName(ExtraType extraType, int seen) + { + var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(extraType)); return seen == 0 ? typeName diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index a28c1d6dfb..f717d9b665 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -29,6 +29,7 @@ public class FindExtrasTests { private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; private readonly Mock _fileSystemMock; + private readonly Mock _itemRepositoryMock; public FindExtrasTests() { @@ -36,8 +37,8 @@ public class FindExtrasTests fixture.Register(() => new NamingOptions()); var configMock = fixture.Freeze>(); configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); - var itemRepository = fixture.Freeze>(); - itemRepository.Setup(i => i.RetrieveItem(It.IsAny())).Returns(null); + _itemRepositoryMock = fixture.Freeze>(); + _itemRepositoryMock.Setup(i => i.RetrieveItem(It.IsAny())).Returns(null); _fileSystemMock = fixture.Freeze>(); _fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny())).Returns(path => new FileSystemMetadata { FullName = path }); @@ -481,6 +482,69 @@ public class FindExtrasTests Assert.Equal("Trailer 4", extras[3].Name); } + [Fact] + public void FindExtras_ExtraNamedByLocalMetadata_KeepsItsNameOnRescan() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var directoryService = new DirectoryService(_fileSystemMock.Object); + var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Trailer", extra.Name); + + // A local metadata file gives the extra a title of its own, which the refresh persists + extra.Name = "Cannes Teaser"; + _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra); + + var rescanned = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Cannes Teaser", rescanned.Name); + } + + [Fact] + public void FindExtras_ExtraKeptItsGeneratedName_IsRenumberedOnRescan() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var directoryService = new DirectoryService(_fileSystemMock.Object); + var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Trailer", extra.Name); + _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra); + + // A trailer sorting before the known one takes the first number, so the known one moves on + files.Add(new FileSystemMetadata + { + FullName = "/movies/Up (2009)/Up (2009)-trailer1.mkv", + IsDirectory = false + }); + + var rescanned = _libraryManager.FindExtras(owner, files, directoryService) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal("Trailer", rescanned["/movies/Up (2009)/Up (2009)-trailer1.mkv"]); + Assert.Equal("Trailer 2", rescanned["/movies/Up (2009)/Up (2009)-trailer2.mkv"]); + } + [Fact] public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle() { -- cgit v1.2.3 From 3d52637ce320aaa592008f4a05cfc3613e0d36fe Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 5 Oct 2026 19:18:43 -0400 Subject: Backport pull request #18093 from jellyfin/release-12.z Stop rebuilding the inherited-tag set per row, and count only the people /Persons can return Original-merge: 63b5a050a9dc943854bae01856f30f1a5a089245 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 5 + .../Item/BaseItemRepository.QueryBuilding.cs | 18 ++- .../Item/BaseItemRepository.TranslateQuery.cs | 6 +- .../Item/PeopleRepository.cs | 7 + .../Entities/InternalPeopleQuery.cs | 7 + .../Item/BaseItemRepositoryInheritedTagTests.cs | 175 +++++++++++++++++++++ .../Item/PeopleRepositoryMustHaveItemTests.cs | 85 ++++++++++ 7 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs (limited to 'Emby.Server.Implementations/Library/LibraryManager.cs') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 6f3df084c0..2d62fdab48 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3657,6 +3657,11 @@ namespace Emby.Server.Implementations.Library public QueryResult GetPeopleItems(InternalPeopleQuery query) { + ArgumentNullException.ThrowIfNull(query); + + // This hands back by-name items, so the people without one are not ours to report. + query.MustHaveItem = true; + var queryResult = _peopleRepository.GetPeople(query); var baseItems = queryResult.Items.Select(i => { diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 1a4c9da41c..4cd02bcd8d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -612,11 +612,12 @@ public sealed partial class BaseItemRepository var blockedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var blockedByAncestor = ItemsBelowTaggedAncestor(context, blockedTagItemIds); baseQuery = baseQuery.Where(e => !blockedTagItemIds.Contains(e.Id) && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) - && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !blockedByAncestor.Contains(e.Id) && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); } @@ -629,10 +630,12 @@ public sealed partial class BaseItemRepository .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var allowedByAncestor = ItemsBelowTaggedAncestor(context, allowedTagItemIds); + baseQuery = baseQuery.Where(e => allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) - || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || allowedByAncestor.Contains(e.Id) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) // People don't carry the tags of the media they appear in and would never match @@ -642,6 +645,17 @@ public sealed partial class BaseItemRepository return baseQuery; } + /// + /// Reads back the items that carry one of the tagged items as an ancestor. + /// + /// The database context. + /// The ids of the items carrying the tag. + /// The ids of the items below one of them. + private static IQueryable ItemsBelowTaggedAncestor(JellyfinDbContext context, IQueryable taggedItemIds) + => context.AncestorIds + .Where(a => taggedItemIds.Contains(a.ParentItemId)) + .Select(a => a.ItemId); + /// /// Builds a filter expression for max parental rating that handles both rated items /// and unrated BoxSets/Playlists (which check linked children's ratings). diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index a7110485b4..e66f18bc6e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1132,11 +1132,12 @@ public sealed partial class BaseItemRepository var blockedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var blockedByAncestor = ItemsBelowTaggedAncestor(context, blockedTagItemIds); baseQuery = baseQuery.Where(e => !blockedTagItemIds.Contains(e.Id) && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) - && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !blockedByAncestor.Contains(e.Id) && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); } @@ -1148,11 +1149,12 @@ public sealed partial class BaseItemRepository var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var allowedByAncestor = ItemsBelowTaggedAncestor(context, allowedTagItemIds); baseQuery = baseQuery.Where(e => allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) - || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || allowedByAncestor.Contains(e.Id) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) // People don't carry the tags of the media they appear in and would never match diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index fcddc09ad9..bc79699486 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -423,6 +423,13 @@ public class PeopleRepository(IDbContextFactory dbProvider, I .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId))); } + if (filter.MustHaveItem) + { + // A credit with no by-name item behind it cannot be handed back as one. + var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + query = query.Where(e => context.BaseItems.Any(b => b.Type == personType && b.Name == e.Name)); + } + if (!filter.ItemId.IsEmpty()) { var itemId = filter.ItemId; diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 8d2a959f4d..50f749367f 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -65,5 +65,12 @@ namespace MediaBrowser.Controller.Entities /// people must satisfy through at least one of the items they are credited on. /// public InternalItemsQuery AccessFilter { get; set; } + + /// + /// Gets or sets a value indicating whether to keep only people that have a by-name item behind + /// them. Set by callers that hand back items rather than credits, so the count they report + /// describes the same people they can return. + /// + public bool MustHaveItem { get; set; } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs new file mode 100644 index 0000000000..d995293923 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs @@ -0,0 +1,175 @@ +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.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers the inherited-tag filters a user's blocked and allowed tags turn into. A tag reaches an item +/// four ways - on the item, on its series, on an ancestor and on its library - and each of them has to +/// keep answering the same after the ancestor check moved off a per-row correlated subquery. +/// +public sealed class BaseItemRepositoryInheritedTagTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + 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 Tag = "Adult"; + + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _otherLibrary = Guid.NewGuid(); + + // Tagged on the item itself. + private readonly Guid _taggedSeries = Guid.NewGuid(); + + // Inherits the tag from the series it belongs to, without an ancestor row for it. + private readonly Guid _episodeOfTaggedSeries = Guid.NewGuid(); + + // Inherits the tag from a season in the middle of its ancestor chain. + private readonly Guid _taggedSeason = Guid.NewGuid(); + private readonly Guid _episodeUnderTaggedSeason = Guid.NewGuid(); + + // Inherits the tag from the library above it. + private readonly Guid _taggedLibrarySeries = Guid.NewGuid(); + + // Carries the tag nowhere, the control the assertions are read against. + private readonly Guid _untaggedSeries = Guid.NewGuid(); + private readonly Guid _untaggedEpisode = Guid.NewGuid(); + + public BaseItemRepositoryInheritedTagTests() + { + using (var ctx = CreateDbContext()) + { + Seed(ctx); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void ExcludeInheritedTags_DropsEveryItemTheTagReaches() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { ExcludeInheritedTags = [Tag] }).ToHashSet(); + + // The blocked library carries the tag itself, so it goes with everything under it. + Assert.Equal( + new[] { _library, _untaggedSeries, _untaggedEpisode }.Order(), + ids.Order()); + } + + [Fact] + public void IncludeInheritedTags_KeepsExactlyTheItemsTheTagReaches() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { IncludeInheritedTags = [Tag] }).ToHashSet(); + + Assert.Equal( + new[] { _otherLibrary, _taggedSeries, _episodeOfTaggedSeries, _taggedSeason, _episodeUnderTaggedSeason, _taggedLibrarySeries }.Order(), + ids.Order()); + } + + [Fact] + public void ExcludeInheritedTags_DropsAnItemReachedOnlyThroughAnAncestor() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Episode], + ExcludeInheritedTags = [Tag] + }); + + Assert.Equal([_untaggedEpisode], ids); + } + + [Fact] + public void ExcludeInheritedTags_WithAnUnusedTag_KeepsEverything() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { ExcludeInheritedTags = ["Unused"] }); + + Assert.Equal(9, ids.Count); + } + + private void Seed(JellyfinDbContext context) + { + AddItem(context, _library, FolderType, "Shows", true); + AddItem(context, _otherLibrary, FolderType, "Blocked library", true); + AddItem(context, _taggedSeries, SeriesType, "Tagged series", true); + AddItem(context, _episodeOfTaggedSeries, EpisodeType, "Episode of tagged series", false, _taggedSeries); + AddItem(context, _taggedSeason, SeasonType, "Tagged season", true); + AddItem(context, _episodeUnderTaggedSeason, EpisodeType, "Episode under tagged season", false); + AddItem(context, _taggedLibrarySeries, SeriesType, "Series in blocked library", true); + AddItem(context, _untaggedSeries, SeriesType, "Untagged series", true); + AddItem(context, _untaggedEpisode, EpisodeType, "Untagged episode", false, _untaggedSeries); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. The + // episode of the tagged series deliberately has none, so the series branch is what has to catch it. + AddAncestors(context, _taggedSeries, _library); + AddAncestors(context, _taggedSeason, _library); + AddAncestors(context, _episodeUnderTaggedSeason, _taggedSeason, _library); + AddAncestors(context, _taggedLibrarySeries, _otherLibrary); + AddAncestors(context, _untaggedSeries, _library); + AddAncestors(context, _untaggedEpisode, _untaggedSeries, _library); + + Tagged(context, _taggedSeries, _taggedSeason, _otherLibrary); + + context.SaveChanges(); + } + + private void AddItem(JellyfinDbContext context, Guid id, string type, string name, bool isFolder, Guid? seriesId = null) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = type, + Name = name, + IsFolder = isFolder, + SeriesId = seriesId + }); + } + + 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 Tagged(JellyfinDbContext context, params Guid[] itemIds) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = Tag, + CleanValue = Tag.GetCleanValue() + }; + + context.ItemValues.Add(itemValue); + foreach (var itemId in itemIds) + { + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = itemId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs new file mode 100644 index 0000000000..f143ce1e20 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs @@ -0,0 +1,85 @@ +using System; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers . A caller that hands back by-name items can +/// only return the people that have one, so the count it reports has to leave out the rest rather than +/// describe a larger set than it can page through. +/// +public sealed class PeopleRepositoryMustHaveItemTests : SqliteDbTestFixture +{ + private readonly PeopleRepository _people; + private readonly Guid _movie = Guid.NewGuid(); + + public PeopleRepositoryMustHaveItemTests() + { + var lookup = new ItemTypeLookup(); + using (var context = CreateDbContext()) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = _movie, + Name = "Movie", + Type = lookup.BaseItemKindNames[BaseItemKind.Movie] + }); + + // Only one of the two credits has a by-name item behind it. + context.BaseItems.Add(new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = "With Item", + Type = lookup.BaseItemKindNames[BaseItemKind.Person] + }); + + context.SaveChanges(); + } + + _people = new PeopleRepository(CreateDbContextFactory(), lookup, Mock.Of()); + _people.UpdatePeople(_movie, [ + new PersonInfo { Name = "With Item", Type = PersonKind.Actor }, + new PersonInfo { Name = "Without Item", Type = PersonKind.Actor } + ]); + } + + [Fact] + public void WithoutMustHaveItem_ReturnsAndCountsEveryCredit() + { + var result = _people.GetPeople(new InternalPeopleQuery()); + + Assert.Equal(2, result.TotalRecordCount); + Assert.Equal(2, result.Items.Count); + } + + [Fact] + public void MustHaveItem_DropsTheCreditWithoutAnItem() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true }); + + Assert.Equal("With Item", Assert.Single(result.Items).Name); + } + + [Fact] + public void MustHaveItem_CountsOnlyWhatItCanReturn() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true }); + + Assert.Equal(1, result.TotalRecordCount); + } + + [Fact] + public void MustHaveItem_CountAgreesWithThePageWhenLimited() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true, Limit = 10 }); + + Assert.Equal(result.Items.Count, result.TotalRecordCount); + } +} -- cgit v1.2.3 From d624b7c57ae586dcaba61b45b6695031ebfba371 Mon Sep 17 00:00:00 2001 From: z0rimo <46391780+z0rimo@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:18:51 -0400 Subject: Backport pull request #18236 from jellyfin/release-12.z Fix user preferences when renaming libraries Original-merge: 9203258175c32b44052d2a0fc5bae9815697c80b Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 33 ++-- .../Controllers/LibraryStructureController.cs | 188 +++++++++++++++++---- MediaBrowser.Controller/Library/ILibraryManager.cs | 18 ++ .../Controllers/LibraryStructureControllerTests.cs | 125 ++++++++++++++ 4 files changed, 319 insertions(+), 45 deletions(-) (limited to 'Emby.Server.Implementations/Library/LibraryManager.cs') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2d62fdab48..b4f6f57bf6 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3052,14 +3052,7 @@ namespace Emby.Server.Implementations.Library CollectionType? viewType, string sortName) { - var parentIdString = parentId.IsEmpty() - ? null - : parentId.ToString("N", CultureInfo.InvariantCulture); - - // The name is either localized (grouped views) or the library folder's own name. - var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); - - var id = GetNewItemId(idValues, typeof(UserView)); + var id = GetNamedViewId(user, parentId, viewType); var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N", CultureInfo.InvariantCulture)); @@ -3117,6 +3110,18 @@ namespace Emby.Server.Implementations.Library return item; } + public Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType) + { + var parentIdString = parentId.IsEmpty() + ? null + : parentId.ToString("N", CultureInfo.InvariantCulture); + + // The name is either localized (grouped views) or the library folder's own name. + var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + + return GetNewItemId(idValues, typeof(UserView)); + } + public UserView GetShadowView( BaseItem parent, CollectionType? viewType, @@ -3126,10 +3131,7 @@ namespace Emby.Server.Implementations.Library var name = parent.Name; var parentId = parent.Id; - - var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty); - - var id = GetNewItemId(idValues, typeof(UserView)); + var id = GetShadowViewId(name, parentId, viewType); var path = parent.Path; @@ -3181,6 +3183,13 @@ namespace Emby.Server.Implementations.Library return item; } + public Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType) + { + var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty); + + return GetNewItemId(idValues, typeof(UserView)); + } + public UserView GetNamedView( string name, Guid parentId, diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 7f63e410a5..9e6bfde085 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -10,6 +10,10 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -36,6 +40,7 @@ public class LibraryStructureController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly ILibraryMonitor _libraryMonitor; private readonly IDirectoryService _directoryService; + private readonly IUserManager _userManager; /// /// Initializes a new instance of the class. @@ -44,16 +49,19 @@ public class LibraryStructureController : BaseJellyfinApiController /// Instance of interface. /// Instance of interface. /// Instance of interface. + /// Instance of interface. public LibraryStructureController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, - IDirectoryService directoryService) + IDirectoryService directoryService, + IUserManager userManager) { _appPaths = serverConfigurationManager.ApplicationPaths; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; _directoryService = directoryService; + _userManager = userManager; } /// @@ -138,7 +146,7 @@ public class LibraryStructureController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] - public ActionResult RenameVirtualFolder( + public async Task RenameVirtualFolder( [FromQuery] string? name, [FromQuery] string? newName, [FromQuery] bool refreshLibrary = false) @@ -173,6 +181,12 @@ public class LibraryStructureController : BaseJellyfinApiController return Conflict($"The media library already exists at {newPath}."); } + var oldLibrary = FindLibrary(currentPath); + // Use the stored ID when possible. The case sensitivity setting may have changed since the library was created. + var oldLibraryId = oldLibrary?.Id ?? _libraryManager.GetNewItemId(currentPath, typeof(CollectionFolder)); + var oldLibraryName = oldLibrary?.Name ?? Path.GetFileName(currentPath); + var newLibraryId = _libraryManager.GetNewItemId(newPath, typeof(CollectionFolder)); + _libraryMonitor.Stop(); try @@ -188,47 +202,155 @@ public class LibraryStructureController : BaseJellyfinApiController } _directoryService.Move(currentPath, newPath); + + await ReplaceLibraryIdInUserPreferencesAsync( + oldLibraryId, + newLibraryId, + oldLibraryName, + Path.GetFileName(newPath), + oldLibrary?.CollectionType).ConfigureAwait(false); } finally { CollectionFolder.OnCollectionFolderChange(); - Task.Run(async () => + _ = Task.Run(() => RefreshAfterRenameAsync(newPath, refreshLibrary)); + } + + return NoContent(); + } + + private async Task RefreshAfterRenameAsync(string newPath, bool refreshLibrary) + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); + var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); + if (newLib is CollectionFolder folder) { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) + _libraryManager.ClearIgnoreRuleCache(); + foreach (var child in folder.GetPhysicalFolders()) { - await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); - if (newLib is CollectionFolder folder) - { - _libraryManager.ClearIgnoreRuleCache(); - foreach (var child in folder.GetPhysicalFolders()) - { - await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); - await child.ValidateChildren(new Progress(), CancellationToken.None).ConfigureAwait(false); - } - } - else - { - _libraryManager.ClearIgnoreRuleCache(); - // We don't know if this one can be validated individually, trigger a new validation - _libraryManager.QueueLibraryScan(); - } - - _libraryManager.ClearIgnoreRuleCache(); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); + await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); + await child.ValidateChildren(new Progress(), CancellationToken.None).ConfigureAwait(false); } - }); + } + else + { + _libraryManager.ClearIgnoreRuleCache(); + // We don't know if this one can be validated individually, trigger a new validation + _libraryManager.QueueLibraryScan(); + } + + _libraryManager.ClearIgnoreRuleCache(); } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + } - return NoContent(); + private CollectionFolder? FindLibrary(string path) + => _libraryManager.GetUserRootFolder().Children + .OfType() + .FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase)); + + private async Task ReplaceLibraryIdInUserPreferencesAsync( + Guid oldId, + Guid newId, + string oldName, + string newName, + CollectionType? viewType) + { + var shadowView = ( + Old: _libraryManager.GetShadowViewId(oldName, oldId, viewType), + New: _libraryManager.GetShadowViewId(newName, newId, viewType)); + + if (oldId.Equals(newId) && shadowView.Old.Equals(shadowView.New)) + { + return; + } + + PreferenceKind[] libraryPreferences = + [ + PreferenceKind.EnabledFolders, + PreferenceKind.BlockedMediaFolders, + PreferenceKind.EnableContentDeletionFromFolders, + PreferenceKind.LatestItemExcludes, + PreferenceKind.MyMediaExcludes, + PreferenceKind.GroupedFolders, + PreferenceKind.OrderedViews + ]; + + // These lists hold what the "My Media" row shows, which includes the views built from a library. + PreferenceKind[] viewPreferences = [PreferenceKind.MyMediaExcludes, PreferenceKind.OrderedViews]; + + foreach (var user in _userManager.GetUsers()) + { + var libraryMap = new Dictionary { [oldId] = newId }; + var viewMap = new Dictionary(libraryMap) + { + [shadowView.Old] = shadowView.New, + [_libraryManager.GetNamedViewId(user, oldId, viewType)] = _libraryManager.GetNamedViewId(user, newId, viewType) + }; + + var changed = false; + + foreach (var kind in libraryPreferences) + { + changed |= ReplaceIds(user, kind, viewPreferences.Contains(kind) ? viewMap : libraryMap); + } + + if (changed) + { + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + } + } + } + + private static bool ReplaceIds(User user, PreferenceKind kind, Dictionary map) + { + var values = user.GetPreference(kind); + var rewritten = new List(values.Length); + var seen = new HashSet(); + var touched = false; + + foreach (var value in values) + { + // Clients write these in both the dashed and the plain form, so compare them parsed. + if (!Guid.TryParse(value, out var parsed)) + { + rewritten.Add(value); + continue; + } + + var isReplaced = map.TryGetValue(parsed, out var replacement) && !replacement.Equals(parsed); + if (isReplaced) + { + parsed = replacement; + touched = true; + } + + if (!seen.Add(parsed)) + { + touched = true; + continue; + } + + var format = value.Contains('-', StringComparison.Ordinal) ? "D" : "N"; + rewritten.Add(isReplaced ? parsed.ToString(format, CultureInfo.InvariantCulture) : value); + } + + if (touched) + { + user.SetPreference(kind, rewritten.ToArray()); + } + + return touched; } /// diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 82de3546f0..ae15c75350 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -483,6 +483,15 @@ namespace MediaBrowser.Controller.Library string sortName, string uniqueId); + /// + /// Gets the identifier of a named user view. + /// + /// The user. + /// The parent identifier. + /// Type of the view. + /// The named view identifier. + Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType); + /// /// Gets the shadow view. /// @@ -495,6 +504,15 @@ namespace MediaBrowser.Controller.Library CollectionType? viewType, string sortName); + /// + /// Gets the identifier of a shadow view. + /// + /// The view name. + /// The parent identifier. + /// Type of the view. + /// The shadow view identifier. + Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType); + /// /// Gets the season number from path. /// diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs index 79b9d1e2c5..3d0840b776 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -1,12 +1,19 @@ using System; +using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Http.Json; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -218,4 +225,122 @@ public sealed class LibraryStructureControllerTests : IClassFixture(); + var userManager = _factory.Services.GetRequiredService(); + var rootFolderPath = _factory.Services.GetRequiredService().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&collectionType=movies&refreshLibrary=true", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, NewName), typeof(CollectionFolder)); + Assert.NotEqual(oldId, newId); + + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType().Single(f => f.Id.Equals(oldId)); + + var otherId = Guid.NewGuid(); + var user = await userManager.CreateUserAsync("rename-prefs-user"); + user.SetPermission(PermissionKind.EnableAllFolders, false); + user.SetPreference(PreferenceKind.EnabledFolders, [otherId, oldId, newId]); + user.SetPreference(PreferenceKind.BlockedMediaFolders, [oldId]); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, [oldId, otherId]); + user.SetPreference(PreferenceKind.LatestItemExcludes, [oldId]); + + // The views built from the library take its id and name as input, so they move with it. + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var oldUserViewId = libraryManager.GetNamedView(user, oldLibrary.Name, oldId, oldLibrary.CollectionType, string.Empty).Id; + user.SetPreference(PreferenceKind.MyMediaExcludes, [otherId.ToString(), oldId.ToString(), oldShadowId.ToString("N", CultureInfo.InvariantCulture)]); + user.SetPreference(PreferenceKind.GroupedFolders, [oldId]); + user.SetPreference(PreferenceKind.OrderedViews, [otherId.ToString(), oldUserViewId.ToString(), oldShadowId.ToString(), oldId.ToString()]); + user.SetPreference(PreferenceKind.BlockedTags, ["keep"]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={NewName}&refreshLibrary=true", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + var reloaded = userManager.GetUserById(user.Id)!; + Assert.Equal([otherId, newId], reloaded.GetPreferenceValues(PreferenceKind.EnabledFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues(PreferenceKind.BlockedMediaFolders)); + Assert.Equal([newId, otherId], reloaded.GetPreferenceValues(PreferenceKind.EnableContentDeletionFromFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues(PreferenceKind.LatestItemExcludes)); + // The endpoint refreshes in the background; make sure the renamed library has been picked up. + await libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true); + var newLibrary = libraryManager.GetUserRootFolder().Children.OfType().Single(f => f.Id.Equals(newId)); + var newShadowId = libraryManager.GetShadowView(newLibrary, newLibrary.CollectionType, string.Empty).Id; + var newUserViewId = libraryManager.GetNamedView(reloaded, newLibrary.Name, newId, newLibrary.CollectionType, string.Empty).Id; + Assert.NotEqual(oldShadowId, newShadowId); + Assert.NotEqual(oldUserViewId, newUserViewId); + Assert.Equal( + [otherId.ToString(), newId.ToString(), newShadowId.ToString("N", CultureInfo.InvariantCulture)], + reloaded.GetPreference(PreferenceKind.MyMediaExcludes)); + Assert.Equal([newId], reloaded.GetPreferenceValues(PreferenceKind.GroupedFolders)); + Assert.Equal( + [otherId.ToString(), newUserViewId.ToString(), newShadowId.ToString(), newId.ToString()], + reloaded.GetPreference(PreferenceKind.OrderedViews)); + Assert.Equal(["keep"], reloaded.GetPreference(PreferenceKind.BlockedTags)); + + // With the block list cleared, the enabled list alone must let the user see the renamed library. + reloaded.SetPreference(PreferenceKind.BlockedMediaFolders, Array.Empty()); + await userManager.UpdateUserAsync(reloaded); + Assert.True(newLibrary.IsVisible(userManager.GetUserById(user.Id)!)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={NewName}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task RenameLibrary_CapitalizationOnly_LeavesPreferencesAlone() + { + const string Name = "rename-case-test"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var libraryManager = _factory.Services.GetRequiredService(); + var userManager = _factory.Services.GetRequiredService(); + var rootFolderPath = _factory.Services.GetRequiredService().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name.ToUpperInvariant()), typeof(CollectionFolder)); + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType().Single(f => f.Id.Equals(oldId)); + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var newShadowId = libraryManager.GetShadowViewId(Name.ToUpperInvariant(), newId, oldLibrary.CollectionType); + + var user = await userManager.CreateUserAsync("rename-case-user"); + user.SetPreference(PreferenceKind.EnabledFolders, [oldId]); + user.SetPreference(PreferenceKind.MyMediaExcludes, [oldShadowId]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={Name.ToUpperInvariant()}&refreshLibrary=false", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + Assert.Equal([newId], userManager.GetUserById(user.Id)!.GetPreferenceValues(PreferenceKind.EnabledFolders)); + Assert.Equal([newShadowId], userManager.GetUserById(user.Id)!.GetPreferenceValues(PreferenceKind.MyMediaExcludes)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name.ToUpperInvariant()}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } } -- cgit v1.2.3 From 3114520c87579eb5cf150e4d764f59fd32f39412 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 5 Oct 2026 19:19:07 -0400 Subject: Backport pull request #18279 from jellyfin/release-12.z Recompute the parental rating score whenever an item is saved Original-merge: 29d7a3ca2444070bda748161a2da777c8e42594e Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 6 ++ .../LibraryManager/ParentalRatingScoreTests.cs | 112 +++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs (limited to 'Emby.Server.Implementations/Library/LibraryManager.cs') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index b4f6f57bf6..16616dd328 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2452,6 +2452,9 @@ namespace Emby.Server.Implementations.Library var parentCollectionType = parent is not null ? GetTopFolderContentType(parent) : null; foreach (var item in items) { + // Parental controls filter on the stored score, so it has to match the ratings being saved. + item.OnMetadataChanged(); + if (item is Video video && video.LocalAlternateVersions.Length > 0) { var videoType = video.GetType(); @@ -2658,6 +2661,9 @@ namespace Emby.Server.Implementations.Library { foreach (var item in items) { + // Parental controls filter on the stored score, so it has to match the ratings being saved. + item.OnMetadataChanged(); + item.DateLastSaved = DateTime.UtcNow; await RunMetadataSavers(item, updateReason).ConfigureAwait(false); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs new file mode 100644 index 0000000000..7adf7ce3b3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager; + +[Collection("LibraryManagerTests")] +public sealed class ParentalRatingScoreTests : IDisposable +{ + private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; + private readonly List _saved = []; + private readonly ILibraryManager? _previousLibraryManager; + private readonly ILocalizationManager? _previousLocalizationManager; + + public ParentalRatingScoreTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + fixture.Freeze>() + .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + fixture.Freeze>() + .Setup(p => p.SaveItems(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((items, _) => _saved.AddRange(items)); + + _libraryManager = fixture.Build() + .Do(s => s.AddParts( + fixture.Create>(), + [], + fixture.Create>(), + fixture.Create>(), + fixture.Create>())) + .Create(); + + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousLocalizationManager = BaseItem.LocalizationManager; + BaseItem.LibraryManager = _libraryManager; + + var localizationManagerMock = new Mock(); + localizationManagerMock.Setup(l => l.GetRatingScore("G", It.IsAny())).Returns(new ParentalRatingScore(0, null)); + localizationManagerMock.Setup(l => l.GetRatingScore("XXX", It.IsAny())).Returns(new ParentalRatingScore(1000, null)); + BaseItem.LocalizationManager = localizationManagerMock.Object; + Video.RecordingsManager ??= fixture.Create(); + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.LocalizationManager = _previousLocalizationManager!; + } + + private static Movie CreateRatedMovie() => new() + { + Id = Guid.NewGuid(), + Name = "Up", + PreferredMetadataCountryCode = "us", + OfficialRating = "G", + InheritedParentalRatingValue = 0 + }; + + [Fact] + public async Task UpdateItemAsync_ChangedCustomRating_StoresItsScore() + { + var movie = CreateRatedMovie(); + movie.CustomRating = "XXX"; + + await _libraryManager.UpdateItemAsync(movie, null!, ItemUpdateType.MetadataEdit, CancellationToken.None); + + Assert.Equal(1000, Assert.Single(_saved).InheritedParentalRatingValue); + } + + [Fact] + public async Task UpdateItemAsync_ClearedRatings_ClearsScore() + { + var movie = CreateRatedMovie(); + movie.OfficialRating = null; + + await _libraryManager.UpdateItemAsync(movie, null!, ItemUpdateType.MetadataEdit, CancellationToken.None); + + Assert.Null(Assert.Single(_saved).InheritedParentalRatingValue); + } + + [Fact] + public void CreateItem_RatedItem_StoresItsScore() + { + var movie = CreateRatedMovie(); + movie.InheritedParentalRatingValue = null; + movie.CustomRating = "XXX"; + + _libraryManager.CreateItem(movie, null); + + Assert.Equal(1000, _saved.Single().InheritedParentalRatingValue); + } +} -- cgit v1.2.3