From a4ff630d1f7881e781f055576d40f3be89c82720 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 12 Aug 2026 08:39:00 +0200 Subject: Fix English metadata blocking localized providers ranked below it --- MediaBrowser.Controller/Providers/MetadataResult.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) (limited to 'MediaBrowser.Controller') diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index ef69885fcf..48fc22a0fb 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -16,11 +16,6 @@ namespace MediaBrowser.Controller.Providers private List<(string Url, ImageType Type)> _remoteImages; private List _people; - public MetadataResult() - { - ResultLanguage = "en"; - } - public List Images { get => _images ??= []; @@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers public T Item { get; set; } + /// + /// Gets or sets the language the fetched metadata is in. + /// public string ResultLanguage { get; set; } public string Provider { get; set; } -- cgit v1.2.3 From a8da0664a387aa871f7c0ee03fd3f53c82d00346 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 22 May 2026 22:17:11 +0200 Subject: Fix GHSA-wwwm-px48-fpvq # Conflicts: # MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs --- MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs | 4 ++-- MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) (limited to 'MediaBrowser.Controller') diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 9a68889352..57c130fa4b 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1318,7 +1318,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(canvasArgs); } - arg.Append(" -i file:\"").Append(subtitlePath).Append('\"'); + arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"'); } if (state.AudioStream is not null && state.AudioStream.IsExternal) @@ -1330,7 +1330,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekAudioParam); } - arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"'); + arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"'); } // Disable auto inserted SW scaler for HW decoders in case of changed resolution. diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index bd516f0a9f..b4626b93fa 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -21,6 +21,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -453,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath); + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, EncodingUtils.NormalizePath(inputPath), EncodingUtils.NormalizePath(outputPath)); await ExtractSubtitlesForFile( inputPath, @@ -631,7 +632,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - outputPath); + EncodingUtils.NormalizePath(outputPath)); } await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); @@ -689,7 +690,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - outputPath); + EncodingUtils.NormalizePath(outputPath)); } if (outputPaths.Count > 0) -- cgit v1.2.3 From 9ba91d4583f3671eaacae8e073fb53288f9b8715 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Thu, 20 Aug 2026 18:10:50 -0400 Subject: Normalize fix, apply in more places --- .../ScheduledTasks/Tasks/AudioNormalizationTask.cs | 2 +- Jellyfin.Api/Controllers/DynamicHlsController.cs | 5 ++- .../MediaEncoding/EncodingHelper.cs | 4 +-- .../Attachments/AttachmentExtractor.cs | 5 ++- .../Encoder/EncodingUtils.cs | 17 ++-------- .../Subtitles/SubtitleEncoder.cs | 8 ++--- src/Jellyfin.Extensions/StringExtensions.cs | 37 ++++++++++++++++++++++ src/Jellyfin.LiveTv/IO/EncodedRecorder.cs | 4 +-- .../StringExtensionsTests.cs | 23 ++++++++++++++ 9 files changed, 76 insertions(+), 29 deletions(-) (limited to 'MediaBrowser.Controller') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index e4939205c9..29b633530f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -174,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol) { t.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), + string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()), false, cancellationToken).ConfigureAwait(false); toSaveDbItems.Add(t); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index a6555a2beb..034a9dea55 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -20,7 +20,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; @@ -1652,9 +1651,9 @@ public class DynamicHlsController : BaseJellyfinApiController segmentFormat, startNumber.ToString(CultureInfo.InvariantCulture), baseUrlParam, - EncodingUtils.NormalizePath(outputTsArg), + outputTsArg.EscapeProcessArgument(), hlsArguments, - EncodingUtils.NormalizePath(outputPath)).Trim(); + outputPath.EscapeProcessArgument()).Trim(); } /// diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 57c130fa4b..10c21ee03c 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1318,7 +1318,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(canvasArgs); } - arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"'); + arg.Append(" -i file:\"").Append(subtitlePath.EscapeProcessArgument()).Append('\"'); } if (state.AudioStream is not null && state.AudioStream.IsExternal) @@ -1330,7 +1330,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekAudioParam); } - arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"'); + arg.Append(" -i \"").Append(state.AudioStream.Path.EscapeProcessArgument()).Append('"'); } // Disable auto inserted SW scaler for HW decoders in case of changed resolution. diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 12a5ab877c..fbe8afc66e 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -14,7 +14,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -160,7 +159,7 @@ namespace MediaBrowser.MediaEncoding.Attachments CultureInfo.InvariantCulture, "-dump_attachment:{0} \"{1}\" ", attachment.Index, - EncodingUtils.NormalizePath(attachmentPath)); + attachmentPath.EscapeProcessArgument()); missingPaths.Add(attachmentPath); } @@ -425,7 +424,7 @@ namespace MediaBrowser.MediaEncoding.Attachments "-dump_attachment:{1} \"{2}\" -i {0} {3}", inputPath, attachmentStreamIndex, - EncodingUtils.NormalizePath(outputPath), + outputPath.EscapeProcessArgument(), hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty); int exitCode; diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs index 2daeac7343..a525dcfa62 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.MediaEncoding.Encoder @@ -42,7 +43,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // If there's more than one we'll need to use the concat command if (inputFiles.Count > 1) { - var files = string.Join('|', inputFiles.Select(NormalizePath)); + var files = string.Join('|', inputFiles.Select(f => f.EscapeProcessArgument())); return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files); } @@ -64,21 +65,9 @@ namespace MediaBrowser.MediaEncoding.Encoder return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", path); } - // Quotes are valid path characters in linux and they need to be escaped here with a leading \ - path = NormalizePath(path); + path = path.EscapeProcessArgument(); return string.Format(CultureInfo.InvariantCulture, "{1}:\"{0}\"", path, inputPrefix); } - - /// - /// Normalizes the path. - /// - /// The path. - /// System.String. - public static string NormalizePath(string path) - { - // Quotes are valid path characters in linux and they need to be escaped here with a leading \ - return path.Replace("\"", "\\\"", StringComparison.Ordinal); - } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index b4626b93fa..e8c636e7fb 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -12,6 +12,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; +using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -21,7 +22,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -454,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, EncodingUtils.NormalizePath(inputPath), EncodingUtils.NormalizePath(outputPath)); + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath.EscapeProcessArgument(), outputPath.EscapeProcessArgument()); await ExtractSubtitlesForFile( inputPath, @@ -632,7 +632,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - EncodingUtils.NormalizePath(outputPath)); + outputPath.EscapeProcessArgument()); } await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); @@ -690,7 +690,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - EncodingUtils.NormalizePath(outputPath)); + outputPath.EscapeProcessArgument()); } if (outputPaths.Count > 0) diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index 906efbcbcc..38f1cf738f 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using ICU4N.Text; @@ -173,5 +174,41 @@ namespace Jellyfin.Extensions return cleaned; } + + /// + /// Escapes an argument so that it survives command line parsing as a single argument when it is wrapped in double quotes by the caller. + /// + /// The argument to escape. + /// The escaped argument. + public static string EscapeProcessArgument(this string value) + { + ArgumentNullException.ThrowIfNull(value); + + var span = value.AsSpan(); + if (!span.Contains('"')) + { + var trailing = span.Length - span.TrimEnd('\\').Length; + return trailing == 0 ? value : string.Concat(value, new string('\\', trailing)); + } + + var escaped = new StringBuilder(value.Length + 8); + var backslashes = 0; + + foreach (var character in span) + { + if (character == '\\') + { + backslashes++; + continue; + } + + escaped + .Append('\\', character == '"' ? (backslashes * 2) + 1 : backslashes) + .Append(character); + backslashes = 0; + } + + return escaped.Append('\\', backslashes * 2).ToString(); + } } } diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index 19c4514766..633c4f95ed 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -188,8 +188,8 @@ namespace Jellyfin.LiveTv.IO var commandLineArgs = string.Format( CultureInfo.InvariantCulture, "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", - inputTempFile, - targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename + inputTempFile.EscapeProcessArgument(), + targetFile.EscapeProcessArgument(), videoArgs, GetAudioArgs(mediaSource), subtitleArgs, diff --git a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs index 028f12afa7..0851570396 100644 --- a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs @@ -75,5 +75,28 @@ namespace Jellyfin.Extensions.Tests var result = str.AsSpan().RightPart(needle).ToString(); Assert.Equal(expectedResult, result); } + + [Theory] + [InlineData("", "")] + [InlineData("/media/movies/Film.mkv", "/media/movies/Film.mkv")] + [InlineData(@"C:\media\movies\Film.mkv", @"C:\media\movies\Film.mkv")] + [InlineData(@"/media/a""b.mkv", @"/media/a\""b.mkv")] + [InlineData(@"/media/a\""b.mkv", @"/media/a\\\""b.mkv")] + [InlineData(@"/media/a\\""b.mkv", @"/media/a\\\\\""b.mkv")] + [InlineData(@"/media/a\b""c.mkv", @"/media/a\b\""c.mkv")] + [InlineData(@"/media/trailing\", @"/media/trailing\\")] + [InlineData(@"/media/evil\"" -f lavfi -i sine .mkv", @"/media/evil\\\"" -f lavfi -i sine .mkv")] + public void EscapeProcessArgument_ValidInput_Corrects(string input, string expectedResult) + { + Assert.Equal(expectedResult, input.EscapeProcessArgument()); + } + + [Theory] + [InlineData("/media/movies/Film with spaces.mkv")] + [InlineData(@"C:\media\movies\Film.mkv")] + public void EscapeProcessArgument_NothingToEscape_ReturnsSameInstance(string input) + { + Assert.Same(input, input.EscapeProcessArgument()); + } } } -- cgit v1.2.3 From 8e80677bdd6471d04748cfcca41f997f2f48b341 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 21 Aug 2026 22:48:47 +0200 Subject: Fix series merging leaking across libraries and under-counting merged children --- .../Item/ItemCountService.cs | 144 ++++++++++++++++++- ...0260723120000_RecomputeSeriesPresentationKey.cs | 102 ------------- ...0260821120000_RecomputeSeriesPresentationKey.cs | 151 +++++++++++++++++++ MediaBrowser.Controller/Entities/TV/Series.cs | 23 ++- .../DescendantQueryHelper.cs | 25 ++++ .../Item/ItemCountServiceTests.cs | 159 ++++++++++++++++++++- 6 files changed, 489 insertions(+), 115 deletions(-) delete mode 100644 Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs (limited to 'MediaBrowser.Controller') diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index a320ba89d1..b276a14536 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService /// public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { + ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); } /// public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId) { + ArgumentNullException.ThrowIfNull(filter); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); return baseQuery.Count(); } @@ -283,10 +285,23 @@ public class ItemCountService : IItemCountService ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); } + private IQueryable BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId) + { + var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId]; + var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds); + + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .Where(b => descendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); + + return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); + } + /// public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId) { @@ -330,9 +345,17 @@ public class ItemCountService : IItemCountService .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); + var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray); + var result = new Dictionary(); foreach (var parentId in parentIds) { + if (mergedChildCounts.TryGetValue(parentId, out var mergedCount)) + { + result[parentId] = mergedCount; + continue; + } + var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0); var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0); @@ -342,6 +365,50 @@ public class ItemCountService : IItemCountService return result; } + private static Dictionary GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList parentIds) + { + var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds) + .Where(group => group.Value.Count > 1) + .ToArray(); + + if (mergedGroups.Length == 0) + { + return []; + } + + // Only merged folders. + var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray(); + var children = dbContext.BaseItems + .AsNoTracking() + .Where(b => b.ParentId.HasValue) + .WhereOneOrMany(memberIds, b => b.ParentId!.Value) + .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey }) + .ToArray() + .GroupBy(b => b.ParentId) + .ToDictionary( + g => g.Key, + g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey) + ? b.Id.ToString("N", CultureInfo.InvariantCulture) + : b.PresentationUniqueKey).ToArray()); + + var result = new Dictionary(); + foreach (var (parentId, members) in mergedGroups) + { + var childKeys = new HashSet(StringComparer.Ordinal); + foreach (var member in members) + { + if (children.TryGetValue(member, out var keys)) + { + childKeys.UnionWith(keys); + } + } + + result[parentId] = childKeys.Count; + } + + return result; + } + /// public Dictionary GetPlayedAndTotalCountBatch(IReadOnlyList folderIds, User user) { @@ -354,10 +421,13 @@ public class ItemCountService : IItemCountService } using var dbContext = _dbProvider.CreateDbContext(); - var folderIdsArray = folderIds.ToArray(); var filter = new InternalItemsQuery(user); var userId = user.Id; + // Merged series and seasons are stored as one row per folder-item sharing a presentation key. + var groups = GetPresentationKeyGroups(dbContext, folderIds); + var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray(); + var leafItems = dbContext.BaseItems .Where(DescendantQueryHelper.IsCountableLeaf); leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); @@ -399,7 +469,7 @@ public class ItemCountService : IItemCountService b => b.Id, (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); - var results = ancestorLeaves + var countsByFolder = ancestorLeaves .Union(linkedLeaves) .Union(linkedFolderLeaves) .GroupBy(x => x.FolderId) @@ -411,9 +481,73 @@ public class ItemCountService : IItemCountService }) .ToDictionary(x => x.FolderId, x => (x.Played, x.Total)); + var results = new Dictionary(); + foreach (var (folderId, members) in groups) + { + var played = 0; + var total = 0; + + // Members of a group are distinct folders, so their leaves cannot overlap. + foreach (var member in members) + { + if (countsByFolder.TryGetValue(member, out var counts)) + { + played += counts.Played; + total += counts.Total; + } + } + + if (total > 0 || played > 0) + { + results[folderId] = (played, total); + } + } + return results; } + private static Dictionary> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList folderIds) + { + var requested = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(folderIds, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToArray(); + + var keys = requested + .Select(e => e.PresentationUniqueKey) + .Where(key => !string.IsNullOrEmpty(key)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + // Every item that is not merged carries a key derived from its own id, so in the common case + // each group resolves back to the single folder that was asked for. + var membersByKey = keys.Length == 0 + ? [] + : dbContext.BaseItems + .AsNoTracking() + .Where(e => e.IsFolder) + .WhereOneOrMany(keys, e => e.PresentationUniqueKey!) + .Select(e => new { e.Id, Key = e.PresentationUniqueKey! }) + .ToArray() + .GroupBy(e => e.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal); + + var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey); + var groups = new Dictionary>(); + foreach (var folderId in folderIds) + { + groups[folderId] = keyById.TryGetValue(folderId, out var key) + && !string.IsNullOrEmpty(key) + && membersByKey.TryGetValue(key, out var members) + && members.Count > 0 + ? members + : [folderId]; + } + + return groups; + } + private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable query, Guid userId) { var result = query diff --git a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs deleted file mode 100644 index 60bb3fd1db..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations; -using Jellyfin.Server.ServerSetupApp; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.Migrations.Routines; - -/// -/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format. -/// -[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))] -[JellyfinMigrationBackup(JellyfinDb = true)] -internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine -{ - private readonly IStartupLogger _logger; - private readonly ILibraryManager _libraryManager; - private readonly IDbContextFactory _dbProvider; - - /// - /// Initializes a new instance of the class. - /// - /// The startup logger. - /// The library manager. - /// The database context factory. - public RecomputeSeriesPresentationKey( - IStartupLogger logger, - ILibraryManager libraryManager, - IDbContextFactory dbProvider) - { - _logger = logger; - _libraryManager = libraryManager; - _dbProvider = dbProvider; - } - - /// - public async Task PerformAsync(CancellationToken cancellationToken) - { - var series = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Series] - }).OfType().ToArray(); - - _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); - - const int ProgressInterval = 250; - var sw = Stopwatch.StartNew(); - var processed = 0; - var updated = 0; - - var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - foreach (var item in series) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (++processed % ProgressInterval == 0) - { - _logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed); - } - - var oldKey = item.PresentationUniqueKey; - var newKey = item.CreatePresentationUniqueKey(); - if (string.Equals(oldKey, newKey, StringComparison.Ordinal)) - { - continue; - } - - // Write only the changed column instead of re-persisting the whole item. - var id = item.Id; - await dbContext.BaseItems - .Where(e => e.Id.Equals(id)) - .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) - .ConfigureAwait(false); - - // Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched - // to the series by it. Re-point every child still carrying the old key in a single set-based - // update so they stay attached without waiting for the next scan. - if (!string.IsNullOrEmpty(oldKey)) - { - await dbContext.BaseItems - .Where(e => e.SeriesPresentationUniqueKey == oldKey) - .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken) - .ConfigureAwait(false); - } - - updated++; - } - } - - _logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed); - } -} diff --git a/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs new file mode 100644 index 0000000000..0e50ec2f47 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library. +/// +[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine +{ + private readonly IStartupLogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IDbContextFactory _dbProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The library manager. + /// The database context factory. + public RecomputeSeriesPresentationKey( + IStartupLogger logger, + ILibraryManager libraryManager, + IDbContextFactory dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _dbProvider = dbProvider; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series] + }).OfType().ToArray(); + + _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); + + const int ProgressInterval = 250; + var sw = Stopwatch.StartNew(); + var newSeriesKeys = new Dictionary(); + var processed = 0; + var updated = 0; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var item in series) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (++processed % ProgressInterval == 0) + { + _logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed); + } + + var newKey = item.CreatePresentationUniqueKey(); + newSeriesKeys[item.Id] = newKey; + + if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + // Write only the changed column instead of re-persisting the whole item. + var id = item.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + // Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so + // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than + // by the old key: that key can be shared by every library holding the series, so matching + // on it would drag the other libraries' children along. + await dbContext.BaseItems + .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}", + updated, + series.Length, + updatedSeasons, + sw.Elapsed); + } + } + + private async Task RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary newSeriesKeys, CancellationToken cancellationToken) + { + // A season's own key embeds its series' key, so it goes stale with it. + var seasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season] + }).OfType().ToArray(); + + var updated = 0; + + foreach (var season in seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Without an index number the season keeps the base key, which carries no series key at all. + if (!season.IndexNumber.HasValue + || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey)) + { + continue; + } + + // Mirrors Season.CreatePresentationUniqueKey. + var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + var id = season.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + return updated; + } +} diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 3ce241aca8..1a1da84b7a 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using System.Threading; @@ -89,15 +90,14 @@ namespace MediaBrowser.Controller.Entities.TV if (!string.IsNullOrEmpty(groupingKey)) { - return AppendPreferredLanguage(groupingKey); + return AddLibrariesToPresentationUniqueKey(groupingKey); } } return base.CreatePresentationUniqueKey(); } - // The owning libraries are deliberately NOT part of the key. - private string AppendPreferredLanguage(string key) + private string AddLibrariesToPresentationUniqueKey(string key) { var lang = GetPreferredMetadataLanguage(); if (!string.IsNullOrEmpty(lang)) @@ -105,7 +105,17 @@ namespace MediaBrowser.Controller.Entities.TV key += "-" + lang; } - return key; + var folders = LibraryManager.GetCollectionFolders(this) + .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (folders.Length == 0) + { + return key; + } + + return key + "-" + string.Join('-', folders); } private string GetNameBasedGroupingKey() @@ -125,20 +135,19 @@ namespace MediaBrowser.Controller.Entities.TV { var seriesKey = GetUniqueSeriesKey(this); - var result = LibraryManager.GetCount(new InternalItemsQuery(user) + var result = LibraryManager.GetItemIds(new InternalItemsQuery(user) { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, IncludeItemTypes = new[] { BaseItemKind.Season }, IsVirtualItem = false, - Limit = 0, DtoOptions = new DtoOptions(false) { EnableImages = false } }); - return result; + return result.Count; } public override int GetRecursiveChildCount(User user) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index bfd0fac34a..909609e35d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -39,6 +39,31 @@ public static class DescendantQueryHelper return descendants.AsQueryable(); } + /// + /// Gets all descendant IDs for multiple parent items in a single traversal. + /// Traverses AncestorIds and LinkedChildren, like . + /// + /// Database context. + /// Parent item IDs. + /// Set of all descendant item IDs (excluding the parent IDs themselves). + public static HashSet GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList parentIds) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(parentIds); + + if (parentIds.Count == 0) + { + return []; + } + + var seedSet = new HashSet(parentIds); + var descendants = TraverseHierarchyDown(context, seedSet); + + descendants.ExceptWith(seedSet); + + return descendants; + } + /// /// Gets a queryable of all owned descendant IDs for a parent item. /// Traverses only AncestorIds (hierarchical ownership), NOT LinkedChildren (associations). diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index 0766ca8d1e..947cf54d85 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -7,6 +7,7 @@ using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using Microsoft.Data.Sqlite; @@ -14,6 +15,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; namespace Jellyfin.Server.Implementations.Tests.Item; @@ -43,10 +45,18 @@ public sealed class ItemCountServiceTests : IDisposable var factory = new Mock>(); factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + var queryHelpers = new Mock(); + queryHelpers + .Setup(h => h.ApplyAccessFiltering( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((JellyfinDbContext _, IQueryable query, InternalItemsQuery _) => query); + _service = new ItemCountService( factory.Object, new Mock().Object, - new Mock().Object); + queryHelpers.Object); } public void Dispose() @@ -106,6 +116,153 @@ public sealed class ItemCountServiceTests : IDisposable Assert.Equal(parentIds.Count, result.Count); } + [Fact] + public void GetCounts_MergedFolders_CountLeavesOfEveryFolderInTheGroup() + { + // Two folder-items of one merged series: same presentation key, a leaf each, one of them played. + var (user, seriesA, seriesB) = SeedMergedSeries(out var playedLeafId); + + var filter = new InternalItemsQuery(user); + + // Either folder-item stands for the whole merged series, so both must report the group. + foreach (var seriesId in new[] { seriesA, seriesB }) + { + Assert.Equal(2, _service.GetTotalCount(filter, seriesId)); + Assert.Equal(1, _service.GetPlayedCount(filter, seriesId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, seriesId)); + } + + var batch = _service.GetPlayedAndTotalCountBatch([seriesA], user); + Assert.Equal((1, 2), batch[seriesA]); + + Assert.NotEqual(Guid.Empty, playedLeafId); + } + + [Fact] + public void GetCounts_UnmergedFolder_CountsOnlyItsOwnLeaves() + { + var (user, _, _) = SeedMergedSeries(out _); + + // A folder with a key of its own must not pick up anything from the merged pair. + var loneSeriesId = Guid.NewGuid(); + var loneLeafId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + var lone = CreateItem(loneSeriesId); + lone.PresentationUniqueKey = "lone-series"; + context.BaseItems.Add(lone); + context.BaseItems.Add(CreateLeaf(loneLeafId)); + context.SaveChanges(); + AddAncestor(context, loneLeafId, loneSeriesId); + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + Assert.Equal(1, _service.GetTotalCount(filter, loneSeriesId)); + Assert.Equal(0, _service.GetPlayedCount(filter, loneSeriesId)); + Assert.Equal((0, 1), _service.GetPlayedAndTotalCount(filter, loneSeriesId)); + } + + [Fact] + public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys() + { + var seriesA = Guid.NewGuid(); + var seriesB = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + foreach (var id in new[] { seriesA, seriesB }) + { + var series = CreateItem(id); + series.PresentationUniqueKey = "merged-series"; + context.BaseItems.Add(series); + } + + // Each folder-item holds a "Season 1"; those two share a key and are one season to the user. + var sharedSeasonA = CreateItem(Guid.NewGuid(), seriesA); + sharedSeasonA.PresentationUniqueKey = "merged-series-001"; + var sharedSeasonB = CreateItem(Guid.NewGuid(), seriesB); + sharedSeasonB.PresentationUniqueKey = "merged-series-001"; + var ownSeason = CreateItem(Guid.NewGuid(), seriesB); + ownSeason.PresentationUniqueKey = "merged-series-002"; + + context.BaseItems.AddRange(sharedSeasonA, sharedSeasonB, ownSeason); + context.SaveChanges(); + } + + var result = _service.GetChildCountBatch([seriesA, seriesB], null); + + Assert.Equal(2, result[seriesA]); + Assert.Equal(2, result[seriesB]); + } + + private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId) + { + var user = new User("count-test", "provider", "reset"); + var seriesA = Guid.NewGuid(); + var seriesB = Guid.NewGuid(); + var leafA = Guid.NewGuid(); + var leafB = Guid.NewGuid(); + playedLeafId = leafA; + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + foreach (var id in new[] { seriesA, seriesB }) + { + var series = CreateItem(id); + series.PresentationUniqueKey = "merged-series"; + context.BaseItems.Add(series); + } + + context.BaseItems.AddRange(CreateLeaf(leafA), CreateLeaf(leafB)); + context.SaveChanges(); + + AddAncestor(context, leafA, seriesA); + AddAncestor(context, leafB, seriesB); + + context.UserData.Add(new UserData + { + ItemId = leafA, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + return (user, seriesA, seriesB); + } + + private static void AddAncestor(JellyfinDbContext context, Guid itemId, Guid parentItemId) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = parentItemId, + Item = null!, + ParentItem = null! + }); + } + + private static BaseItemEntity CreateLeaf(Guid id) + { + return new BaseItemEntity + { + Id = id, + Type = "Episode", + IsFolder = false, + IsVirtualItem = false, + PresentationUniqueKey = id.ToString("N") + }; + } + private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null) { return new BaseItemEntity -- cgit v1.2.3 From 8c0775e9412082ab427bb93cf40ecca6ce83c492 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 25 Aug 2026 20:19:52 +0200 Subject: Bind the folder name an item-by-name entity resolves to --- .../Entities/Audio/MusicArtist.cs | 5 +-- .../Entities/Audio/MusicGenre.cs | 5 +-- MediaBrowser.Controller/Entities/BaseItem.cs | 41 +++++++++++++++++ MediaBrowser.Controller/Entities/Genre.cs | 5 +-- MediaBrowser.Controller/Entities/Person.cs | 5 +-- MediaBrowser.Controller/Entities/Studio.cs | 5 +-- MediaBrowser.Controller/Entities/Year.cs | 5 +-- .../Entities/BaseItemTests.cs | 52 ++++++++++++++++++++++ 8 files changed, 99 insertions(+), 24 deletions(-) (limited to 'MediaBrowser.Controller') diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index c25694aba5..1e2d94d2a4 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -173,10 +173,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.ArtistsPath, validName); } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index 65669e6804..23b3341dbc 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -80,10 +80,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.MusicGenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 28f40cb7fa..d030c8f420 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -48,6 +48,10 @@ namespace MediaBrowser.Controller.Entities public const string ThemeSongFileName = "theme"; + // Well below the 255 byte limit of the common Linux filesystems and the 255 character limit + // of Windows, so the files inside the folder still fit within MAX_PATH. + private const int MaxItemByNameFolderNameBytes = 128; + /// /// The supported image extensions. /// @@ -941,6 +945,43 @@ namespace MediaBrowser.Controller.Entities return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration); } + /// + /// Turns an item-by-name entity's name into a folder name every supported filesystem accepts. + /// + /// The entity's name. + /// The folder name. + public static string GetItemByNameFolderName(string name) + { + // Trim the period at the end because windows will have a hard time with that + var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.'); + + // Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be + // turned into a folder at all - and an entity with no folder can never be created, which + // leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan. + // Only broken provider data gets this long, but it still has to resolve to something, so + // keep a readable prefix and let a hash of the whole name tell two of them apart. + if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes) + { + return validName; + } + + var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var budget = MaxItemByNameFolderNameBytes - suffix.Length; + var length = Math.Min(validName.Length, budget); + while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget) + { + length--; + } + + // Never cut a surrogate pair in half, the lone half is not a valid file name character. + if (length > 0 && char.IsHighSurrogate(validName[length - 1])) + { + length--; + } + + return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix); + } + /// /// Cleans a raw name into its sortable form by applying the configured sort rules. /// diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index 6ec78a270e..ef8acaef92 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -83,10 +83,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.GenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index 14325d971a..bba5005eed 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -98,10 +98,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validFilename = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validFilename = normalizeName ? GetItemByNameFolderName(name) : name; string subFolderPrefix = null; diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs index 9103b09a95..a944b356c8 100644 --- a/MediaBrowser.Controller/Entities/Studio.cs +++ b/MediaBrowser.Controller/Entities/Studio.cs @@ -78,10 +78,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.StudioPath, validName); } diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index 37820296cc..03fb2156d3 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -85,10 +85,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.YearPath, validName); } diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index e34eb0bda3..86bac4256a 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; @@ -27,6 +28,57 @@ namespace Jellyfin.Controller.Tests.Entities; public class BaseItemTests { + [Fact] + public void GetItemByNameFolderName_ShortName_IsKeptAsIs() + { + SetupPassThroughFileSystem(); + + Assert.Equal("Mairghread Scott", BaseItem.GetItemByNameFolderName("Mairghread Scott.")); + } + + [Fact] + public void GetItemByNameFolderName_OverlongName_FitsInAPathComponent() + { + SetupPassThroughFileSystem(); + + // What a provider result that concatenated a whole credit list into one name looks like. + var name = string.Join(", ", Enumerable.Repeat("Jerry Siegel (created by: Superman)", 20)); + + var folderName = BaseItem.GetItemByNameFolderName(name); + + Assert.True(Encoding.UTF8.GetByteCount(folderName) <= 128); + Assert.StartsWith("Jerry Siegel (created by: Superman)", folderName, StringComparison.Ordinal); + } + + [Fact] + public void GetItemByNameFolderName_OverlongNamesSharingAPrefix_StayApart() + { + SetupPassThroughFileSystem(); + + var prefix = new string('a', 200); + + Assert.NotEqual( + BaseItem.GetItemByNameFolderName(prefix + "Joe Shuster"), + BaseItem.GetItemByNameFolderName(prefix + "Bob Kane")); + } + + [Fact] + public void GetItemByNameFolderName_OverlongName_IsStable() + { + SetupPassThroughFileSystem(); + + var name = new string('a', 300); + + Assert.Equal(BaseItem.GetItemByNameFolderName(name), BaseItem.GetItemByNameFolderName(name)); + } + + private static void SetupPassThroughFileSystem() + { + var fileSystem = new Mock(); + fileSystem.Setup(x => x.GetValidFilename(It.IsAny())).Returns((string name) => name); + BaseItem.FileSystem = fileSystem.Object; + } + [Theory] [InlineData("", "")] [InlineData("1", "0000000001")] -- cgit v1.2.3 From 6978dfc29441eb1a37571be14fe165c622bff3c7 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 25 Aug 2026 20:23:14 +0200 Subject: Delete a credit once no item maps to it any more --- .../Library/LibraryManager.cs | 6 ++ .../Library/Validators/PeopleValidator.cs | 10 ++- .../Item/PeopleRepository.cs | 32 ++++++++++ MediaBrowser.Controller/Library/ILibraryManager.cs | 6 ++ .../Persistence/IPeopleRepository.cs | 6 ++ .../Item/PeopleRepositoryUpdatePeopleTests.cs | 73 ++++++++++++++++++++++ 6 files changed, 132 insertions(+), 1 deletion(-) (limited to 'MediaBrowser.Controller') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2bba659a23..6cf9f33e6e 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3558,6 +3558,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// + public int DeleteOrphanedCredits() + { + return _peopleRepository.DeleteOrphanedCredits(); + } + /// public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes) { diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index dacef102dd..078a0b921d 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -49,6 +49,14 @@ public class PeopleValidator /// Task. public async Task ValidatePeople(CancellationToken cancellationToken, IProgress progress) { + // Before the refresh below walks them: a credit no item maps to any more stands for nothing, + // and while it is there the person it names cannot reach the dead-person sweep either. + var numOrphaned = _libraryManager.DeleteOrphanedCredits(); + if (numOrphaned > 0) + { + _logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned); + } + var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); var numComplete = 0; @@ -115,6 +123,6 @@ public class PeopleValidator progress.Report(100); - _logger.LogInformation("People validation complete"); + _logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned); } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index aaa363b046..da2ad033ec 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -194,12 +194,44 @@ public class PeopleRepository(IDbContextFactory dbProvider, I listOrder++; } + var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray(); context.PeopleBaseItemMap.RemoveRange(existingMaps); + context.SaveChanges(); + + // Nothing else ever deletes a credit row, so one left without a single mapping outlives the + // credit it stood for: it keeps a person of that name off the dead-person sweep, which only + // sees items no credit names, and keeps the name in every by-name list. That is how a credit + // a provider dropped, or one a broken provider result invented, becomes impossible to clean up. + DeleteCreditsWithoutMapping(context, droppedCredits); + context.SaveChanges(); transaction.Commit(); } + /// + public int DeleteOrphanedCredits() + { + using var context = _dbProvider.CreateDbContext(); + + return DeleteCreditsWithoutMapping(context, null); + } + + // A null candidate list sweeps every credit, anything else only the ones just unmapped. + private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList? candidates) + { + if (candidates is not null && candidates.Count == 0) + { + return 0; + } + + var credits = candidates is null + ? context.Peoples.AsQueryable() + : context.Peoples.WhereOneOrMany(candidates, e => e.Id); + + return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete(); + } + /// public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes) { diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index ca686fbd9d..2a6ea214b8 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -605,6 +605,12 @@ namespace MediaBrowser.Controller.Library /// List<System.String>. IReadOnlyList GetPeopleNames(InternalPeopleQuery query); + /// + /// Deletes every credit that no item maps to any more. + /// + /// The number of credits that were deleted. + int DeleteOrphanedCredits(); + /// /// Gets the distinct people names per item for multiple items. /// diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index 9811241d31..15183a8806 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -33,6 +33,12 @@ public interface IPeopleRepository /// The list of people names matching the filter. IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); + /// + /// Deletes every credit that no item maps to any more. + /// + /// The number of credits that were deleted. + int DeleteOrphanedCredits(); + /// /// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table. /// diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs index 54565c5787..649458f733 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs @@ -142,6 +142,79 @@ public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture Assert.Equal("Hero", map.Role); } + [Fact] + public void UpdatePeople_CreditDroppedByTheProvider_LeavesNoCreditRowBehind() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person B", PersonKind.Actor, "Villain") + ]); + + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + using var ctx = CreateDbContext(); + Assert.Equal(["Person A"], ctx.Peoples.Select(e => e.Name).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditStillHeldByAnotherItem_IsKept() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + _repository.UpdatePeople(AddMovie("Other Movie"), [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + _repository.UpdatePeople(_itemId, []); + + using var after = CreateDbContext(); + Assert.Single(after.Peoples); + Assert.Single(after.PeopleBaseItemMap); + } + + [Fact] + public void DeleteOrphanedCredits_CreditNoItemMapsTo_IsDeleted() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + using (var ctx = CreateDbContext()) + { + // The state a credit was left in before UpdatePeople cleaned up after itself. + ctx.PeopleBaseItemMap.RemoveRange(ctx.PeopleBaseItemMap); + ctx.SaveChanges(); + } + + Assert.Equal(1, _repository.DeleteOrphanedCredits()); + + using var after = CreateDbContext(); + Assert.Empty(after.Peoples); + } + + [Fact] + public void DeleteOrphanedCredits_CreditAnItemMapsTo_IsKept() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + Assert.Equal(0, _repository.DeleteOrphanedCredits()); + + using var after = CreateDbContext(); + Assert.Single(after.Peoples); + } + + private Guid AddMovie(string name) + { + var id = Guid.NewGuid(); + using var ctx = CreateDbContext(); + ctx.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie], + Name = name, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }); + ctx.SaveChanges(); + return id; + } + private static PersonInfo CreatePerson(string name, PersonKind type, string role) { return new PersonInfo -- cgit v1.2.3