aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Localization/Core/bg-BG.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/hi.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-BR.json3
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs148
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs (renamed from Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs)79
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs23
-rw-r--r--MediaBrowser.Controller/Providers/MetadataResult.cs8
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs44
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs24
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs64
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs64
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs5
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs58
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs38
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs159
23 files changed, 661 insertions, 118 deletions
diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json
index 0710a39708..3d49675c63 100644
--- a/Emby.Server.Implementations/Localization/Core/bg-BG.json
+++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json
@@ -106,5 +106,20 @@
"TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.",
"TaskExtractMediaSegments": "Сканиране за сегменти",
"CleanupUserDataTask": "Задача за почистване на потребителски данни",
- "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни."
+ "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.",
+ "LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}",
+ "NameExtraBehindTheScenes": "Зад кулисите",
+ "NameExtraScene": "Сцена",
+ "NameExtraShort": "Откъс",
+ "NameExtraThemeVideo": "Тематично видео",
+ "NameExtraTrailer": "Трейлър",
+ "NameExtraUnknown": "Екстра",
+ "NameExtraClip": "Клип",
+ "NameExtraDeletedScene": "Изтрита Сцена",
+ "NameExtraFeaturette": "Кратък филм",
+ "NameExtraInterview": "Интервю",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Пример",
+ "NameExtraThemeSong": "Тема-песен",
+ "Original": "Оригинал"
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index 5f1759e9d0..1f69fc1f55 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -24,8 +24,8 @@
"Music": "Music",
"MusicVideos": "Music Videos",
"NameInstallFailed": "{0} installation failed",
- "NameSeasonNumber": "Season {0}",
- "NameSeasonUnknown": "Season Unknown",
+ "NameSeasonNumber": "Series {0}",
+ "NameSeasonUnknown": "Series Unknown",
"NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.",
"NotificationOptionApplicationUpdateAvailable": "Application update available",
"NotificationOptionApplicationUpdateInstalled": "Application update installed",
diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json
index 5fbf61c627..f4b1f86d1d 100644
--- a/Emby.Server.Implementations/Localization/Core/hi.json
+++ b/Emby.Server.Implementations/Localization/Core/hi.json
@@ -106,5 +106,20 @@
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
- "Original": "असली"
+ "Original": "असली",
+ "LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा",
+ "NameExtraBehindTheScenes": "परदे के पीछे",
+ "NameExtraClip": "क्लिप",
+ "NameExtraDeletedScene": "हटाया गया दृश्य",
+ "NameExtraFeaturette": "फीचरेट",
+ "NameExtraInterview": "साक्षात्कार",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "नमूना",
+ "NameExtraScene": "दृश्य",
+ "NameExtraShort": "शॉर्ट",
+ "NameExtraThemeSong": "थीम सॉन्ग",
+ "NameExtraThemeVideo": "थीम वीडियो",
+ "NameExtraTrailer": "ट्रेलर",
+ "NameExtraUnknown": "अतिरिक्त",
+ "CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json
index 031c6e17c4..997d534fea 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-BR.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json
@@ -120,5 +120,6 @@
"NameExtraThemeVideo": "Vídeo de Abertura",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Extra",
- "NameExtraFeaturette": "Nos Bastidores"
+ "NameExtraFeaturette": "Nos Bastidores",
+ "NameExtraInterview": "Entrevista"
}
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index a320ba89d1..c42b5f9581 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService
/// <inheritdoc/>
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));
}
/// <inheritdoc/>
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<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
+ {
+ var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();
+
+ var baseQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(descendantIds, b => b.Id)
+ .Where(DescendantQueryHelper.IsCountableLeaf);
+
+ return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
+ }
+
/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
@@ -294,9 +309,9 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
+ var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
var baseQuery = dbContext.BaseItems
- .Where(b => allDescendantIds.Contains(b.Id))
+ .WhereOneOrMany(allDescendantIds, b => b.Id)
.Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
@@ -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<Guid, int>();
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<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> 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<Guid, int>();
+ foreach (var (parentId, members) in mergedGroups)
+ {
+ var childKeys = new HashSet<string>(StringComparer.Ordinal);
+ foreach (var member in members)
+ {
+ if (children.TryGetValue(member, out var keys))
+ {
+ childKeys.UnionWith(keys);
+ }
+ }
+
+ result[parentId] = childKeys.Count;
+ }
+
+ return result;
+ }
+
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> 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<Guid, (int Played, int Total)>();
+ 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<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> 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<Guid, List<Guid>>();
+ 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<BaseItemEntity> query, Guid userId)
{
var result = query
diff --git a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs
index 60bb3fd1db..0e50ec2f47 100644
--- a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -15,9 +17,9 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
-/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
+/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library.
/// </summary>
-[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
+[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
@@ -53,6 +55,7 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
const int ProgressInterval = 250;
var sw = Stopwatch.StartNew();
+ var newSeriesKeys = new Dictionary<Guid, string>();
var processed = 0;
var updated = 0;
@@ -68,9 +71,10 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
_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))
+ newSeriesKeys[item.Id] = newKey;
+
+ if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}
@@ -82,21 +86,66 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
.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);
- }
+ // 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<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> 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<Season>().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++;
}
- _logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
+ 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/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<PersonInfo> _people;
- public MetadataResult()
- {
- ResultLanguage = "en";
- }
-
public List<LocalImageInfo> Images
{
get => _images ??= [];
@@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers
public T Item { get; set; }
+ /// <summary>
+ /// Gets or sets the language the fetched metadata is in.
+ /// </summary>
public string ResultLanguage { get; set; }
public string Provider { get; set; }
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
index 2bd2676ceb..a06de95fce 100644
--- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
@@ -204,7 +204,7 @@ public class ComicBookInfoProvider : IComicProvider
{
try
{
- return CultureInfo.GetCultureInfo(language).DisplayName;
+ return CultureInfo.GetCultureInfo(language).TwoLetterISOLanguageName;
}
catch (CultureNotFoundException)
{
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
index cfd22a850e..e3d1f544cf 100644
--- a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
@@ -52,7 +52,7 @@ public class ExternalComicInfoProvider : IComicProvider
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
- ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName);
return metadataResult;
}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
index 19062452b9..4b14837441 100644
--- a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
@@ -50,7 +50,7 @@ public class InternalComicInfoProvider : IComicProvider
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
- ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName);
return metadataResult;
}
diff --git a/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs
new file mode 100644
index 0000000000..92a16feaee
--- /dev/null
+++ b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs
@@ -0,0 +1,44 @@
+using System;
+
+namespace MediaBrowser.Providers.Manager;
+
+/// <summary>
+/// Helpers for comparing the language of fetched metadata with the language that was requested.
+/// </summary>
+internal static class MetadataLanguageUtils
+{
+ /// <summary>
+ /// Gets the language subtag of a language tag, e.g. "es" for "es-ES".
+ /// </summary>
+ /// <param name="language">The language tag.</param>
+ /// <returns>The language subtag, lowercased, or <c>null</c> if none was given.</returns>
+ public static string? GetLanguageSubtag(string? language)
+ {
+ if (string.IsNullOrEmpty(language))
+ {
+ return null;
+ }
+
+ var separator = language.IndexOf('-', StringComparison.Ordinal);
+
+ return (separator == -1 ? language : language[..separator]).ToLowerInvariant();
+ }
+
+ /// <summary>
+ /// Determines whether a provider result can be considered to be in the requested language.
+ /// </summary>
+ /// <param name="resultLanguage">The language the provider reported for its result, if any.</param>
+ /// <param name="preferredLanguage">The language that was requested, if any.</param>
+ /// <returns><c>true</c> if the result is in the requested language or either language is unknown.</returns>
+ public static bool MatchesPreferredLanguage(string? resultLanguage, string? preferredLanguage)
+ {
+ // A provider that doesn't report a language cannot be judged, assume it honored the request
+ if (string.IsNullOrEmpty(resultLanguage) || string.IsNullOrEmpty(preferredLanguage))
+ {
+ return true;
+ }
+
+ // Compare on the language subtag only so that e.g. "es" matches "es-ES"
+ return string.Equals(GetLanguageSubtag(resultLanguage), GetLanguageSubtag(preferredLanguage), StringComparison.Ordinal);
+ }
+}
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index d11db8f531..c810fa523e 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -939,6 +939,10 @@ namespace MediaBrowser.Providers.Manager
private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, bool replaceData, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken)
{
var refreshResult = new RefreshResult();
+ var preferredLanguage = id?.MetadataLanguage;
+
+ var overviewIsFallback = false;
+ var taglineIsFallback = false;
if (id is not null)
{
@@ -958,6 +962,26 @@ namespace MediaBrowser.Providers.Manager
{
result.Provider = provider.Name;
+ if (MetadataLanguageUtils.MatchesPreferredLanguage(result.ResultLanguage, preferredLanguage))
+ {
+ if (overviewIsFallback && !string.IsNullOrEmpty(result.Item.Overview))
+ {
+ temp.Item.Overview = null;
+ overviewIsFallback = false;
+ }
+
+ if (taglineIsFallback && !string.IsNullOrEmpty(result.Item.Tagline))
+ {
+ temp.Item.Tagline = null;
+ taglineIsFallback = false;
+ }
+ }
+ else
+ {
+ overviewIsFallback |= string.IsNullOrEmpty(temp.Item.Overview) && !string.IsNullOrEmpty(result.Item.Overview);
+ taglineIsFallback |= string.IsNullOrEmpty(temp.Item.Tagline) && !string.IsNullOrEmpty(result.Item.Tagline);
+ }
+
LogInvalidProviderIds(result, providerName, logName);
MergeData(result, temp, [], replaceData, false);
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
index 0acd44afbe..1903adfbdd 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
@@ -21,6 +21,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
+using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Music;
namespace MediaBrowser.Providers.Plugins.AudioDb
@@ -77,7 +78,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
{
result.Item = new MusicAlbum();
result.HasMetadata = true;
- ProcessResult(result.Item, obj.album[0], info.MetadataLanguage);
+ ProcessResult(result, obj.album[0], info.MetadataLanguage);
}
}
}
@@ -85,8 +86,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
return result;
}
- private void ProcessResult(MusicAlbum item, Album result, string preferredLanguage)
+ private void ProcessResult(MetadataResult<MusicAlbum> metadataResult, Album result, string preferredLanguage)
{
+ var item = metadataResult.Item;
+
if (Plugin.Instance.Configuration.ReplaceAlbumName && !string.IsNullOrWhiteSpace(result.strAlbum))
{
item.Album = result.strAlbum;
@@ -113,43 +116,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
item.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, result.strMusicBrainzArtistID);
item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, result.strMusicBrainzID);
- string overview = null;
-
- if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionDE;
- }
- else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionFR;
- }
- else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionNL;
- }
- else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionRU;
- }
- else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionIT;
- }
- else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionPT;
- }
+ var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage);
+ var overview = GetDescription(result, language);
if (string.IsNullOrWhiteSpace(overview))
{
overview = string.IsNullOrWhiteSpace(result.strDescriptionEN)
? result.strDescription
: result.strDescriptionEN;
+
+ // The description is not in the requested language, mark it as English so it does not
+ // block a provider further down the list that can serve the requested language
+ metadataResult.ResultLanguage = "en";
+ }
+ else
+ {
+ metadataResult.ResultLanguage = language;
}
item.Overview = (overview ?? string.Empty).StripHtml();
}
+ private static string GetDescription(Album result, string language)
+ => language switch
+ {
+ "de" => result.strDescriptionDE,
+ "en" => result.strDescriptionEN,
+ "es" => result.strDescriptionES,
+ "fr" => result.strDescriptionFR,
+ "he" => result.strDescriptionIL,
+ "hu" => result.strDescriptionHU,
+ "it" => result.strDescriptionIT,
+ "ja" => result.strDescriptionJP,
+ "nl" => result.strDescriptionNL,
+ "no" or "nb" or "nn" => result.strDescriptionNO,
+ "pl" => result.strDescriptionPL,
+ "pt" => result.strDescriptionPT,
+ "ru" => result.strDescriptionRU,
+ "sv" => result.strDescriptionSE,
+ "zh" => result.strDescriptionCN,
+ _ => null
+ };
+
internal async Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken)
{
var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId);
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
index c4f4833857..2d9fe4448f 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
@@ -22,6 +22,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
+using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Music;
namespace MediaBrowser.Providers.Plugins.AudioDb
@@ -148,7 +149,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
{
result.Item = new MusicArtist();
result.HasMetadata = true;
- ProcessResult(result.Item, artist, info.MetadataLanguage);
+ ProcessResult(result, artist, info.MetadataLanguage);
}
return result;
@@ -193,8 +194,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
return null;
}
- private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage)
+ private void ProcessResult(MetadataResult<MusicArtist> metadataResult, Artist result, string preferredLanguage)
{
+ var item = metadataResult.Item;
+
if (!string.IsNullOrWhiteSpace(result.strWebsite))
{
item.HomePageUrl = result.strWebsite;
@@ -229,43 +232,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist);
item.SetProviderId(MetadataProvider.MusicBrainzArtist, result.strMusicBrainzID);
- string overview = null;
-
- if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyDE;
- }
- else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyFR;
- }
- else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyNL;
- }
- else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyRU;
- }
- else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyIT;
- }
- else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyPT;
- }
+ var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage);
+ var overview = GetBiography(result, language);
if (string.IsNullOrWhiteSpace(overview))
{
overview = string.IsNullOrWhiteSpace(result.strBiographyEN)
? result.strBiography
: result.strBiographyEN;
+
+ // The biography is not in the requested language, mark it as English so it does not
+ // block a provider further down the list that can serve the requested language
+ metadataResult.ResultLanguage = "en";
+ }
+ else
+ {
+ metadataResult.ResultLanguage = language;
}
item.Overview = (overview ?? string.Empty).StripHtml();
}
+ private static string GetBiography(Artist result, string language)
+ => language switch
+ {
+ "de" => result.strBiographyDE,
+ "en" => result.strBiographyEN,
+ "es" => result.strBiographyES,
+ "fr" => result.strBiographyFR,
+ "he" => result.strBiographyIL,
+ "hu" => result.strBiographyHU,
+ "it" => result.strBiographyIT,
+ "ja" => result.strBiographyJP,
+ "nl" => result.strBiographyNL,
+ "no" or "nb" or "nn" => result.strBiographyNO,
+ "pl" => result.strBiographyPL,
+ "pt" => result.strBiographyPT,
+ "ru" => result.strBiographyRU,
+ "sv" => result.strBiographySE,
+ "zh" => result.strBiographyCN,
+ _ => null
+ };
+
internal async Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken)
{
var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs
index ccff31ebaa..437a997c11 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs
@@ -44,7 +44,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var result = new MetadataResult<Episode>
{
Item = new Episode(),
- QueriedById = true
+ QueriedById = true,
+ // OMDb is not localized, everything it returns is English
+ ResultLanguage = "en"
};
// Allowing this will dramatically increase scan times
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
index e84f1359b7..7b245ea5a7 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
@@ -218,7 +218,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var result = new MetadataResult<T>
{
Item = new T(),
- QueriedById = true
+ QueriedById = true,
+ // OMDb is not localized, everything it returns is English
+ ResultLanguage = "en"
};
var imdbId = info.GetProviderId(MetadataProvider.Imdb);
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
index 11ac477378..0a75b71264 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
@@ -114,7 +114,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
}
}
- var result = new MetadataResult<BoxSet>();
+ var result = new MetadataResult<BoxSet>
+ {
+ ResultLanguage = language
+ };
if (tmdbId > 0)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
index 61294676f7..695f347a9a 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
@@ -102,7 +102,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
}
}
- var result = new MetadataResult<Person>();
+ var result = new MetadataResult<Person>
+ {
+ ResultLanguage = info.MetadataLanguage
+ };
if (personTmdbId > 0)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
index 06313810a1..9b8803f171 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
@@ -40,7 +40,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
/// <inheritdoc />
public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken)
{
- var result = new MetadataResult<Season>();
+ var result = new MetadataResult<Season>
+ {
+ ResultLanguage = info.MetadataLanguage
+ };
var config = Plugin.Instance.Configuration;
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId);
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
index 6b08f8dd7e..b821476390 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
@@ -32,18 +32,36 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
- var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentId);
+ return AllDescendants(context, [parentId])
+ .Where(e => !e.Equals(parentId))
+ .Distinct();
+ }
- var hierarchyDescendants = ClosureDescendants(context, closureRoots);
+ /// <summary>
+ /// Gets all descendant IDs for multiple parent items in a single traversal.
+ /// Traverses AncestorIds and LinkedChildren, like <see cref="GetAllDescendantIds"/>, but resolves
+ /// the roots once for all seeds instead of once per seed.
+ /// </summary>
+ /// <param name="context">Database context.</param>
+ /// <param name="parentIds">Parent item IDs.</param>
+ /// <returns>Set of all descendant item IDs (excluding the parent IDs themselves).</returns>
+ public static HashSet<Guid> GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(parentIds);
- var linkedDescendants = context.LinkedChildren
- .WhereOneOrMany(linkRoots, e => e.ParentId)
- .Select(e => e.ChildId);
+ if (parentIds.Count == 0)
+ {
+ return [];
+ }
- return hierarchyDescendants
- .Concat(linkedDescendants)
- .Where(e => !e.Equals(parentId))
- .Distinct();
+ var descendants = AllDescendants(context, parentIds)
+ .Distinct()
+ .ToHashSet();
+
+ descendants.ExceptWith(parentIds);
+
+ return descendants;
}
/// <summary>
@@ -216,6 +234,18 @@ public static class DescendantQueryHelper
return query;
}
+ private static IQueryable<Guid> AllDescendants(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
+ {
+ var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentIds);
+
+ var linkedDescendants = context.LinkedChildren
+ .WhereOneOrMany(linkRoots, e => e.ParentId)
+ .Select(e => e.ChildId);
+
+ return ClosureDescendants(context, closureRoots)
+ .Concat(linkedDescendants);
+ }
+
private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots)
{
var direct = context.AncestorIds
@@ -286,12 +316,12 @@ public static class DescendantQueryHelper
// Resolves the roots the descendant sub-selects are anchored on: those contributing their closure,
// and those contributing their linked children.
- private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId)
+ private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
{
- var closureRoots = new List<Guid> { parentId };
- var linkRoots = new List<Guid> { parentId };
- var visited = new HashSet<Guid> { parentId };
- var frontier = new List<Guid> { parentId };
+ var visited = new HashSet<Guid>(parentIds);
+ var closureRoots = visited.ToList();
+ var linkRoots = visited.ToList();
+ var frontier = visited.ToList();
while (frontier.Count != 0)
{
diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs
new file mode 100644
index 0000000000..d3b0b47465
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs
@@ -0,0 +1,38 @@
+using MediaBrowser.Providers.Manager;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Manager
+{
+ public class MetadataLanguageUtilsTests
+ {
+ [Theory]
+ [InlineData("es", "es")]
+ [InlineData("es-ES", "es")]
+ [InlineData("pt-BR", "pt")]
+ [InlineData("ES", "es")]
+ [InlineData(null, null)]
+ [InlineData("", null)]
+ public void GetLanguageSubtag_ReturnsLowercasedSubtag(string? language, string? expected)
+ {
+ Assert.Equal(expected, MetadataLanguageUtils.GetLanguageSubtag(language));
+ }
+
+ [Theory]
+ [InlineData("es", "es", true)]
+ [InlineData("es", "es-ES", true)]
+ [InlineData("es-MX", "es-ES", true)]
+ [InlineData("ES", "es", true)]
+ [InlineData("en", "en", true)]
+ [InlineData("en", "es-ES", false)]
+ [InlineData("en", "es", false)]
+ // An unknown language on either side cannot be judged and is assumed to match
+ [InlineData(null, "es", true)]
+ [InlineData("", "es", true)]
+ [InlineData("en", null, true)]
+ [InlineData("en", "", true)]
+ public void MatchesPreferredLanguage_ComparesLanguageSubtag(string? resultLanguage, string? preferredLanguage, bool expected)
+ {
+ Assert.Equal(expected, MetadataLanguageUtils.MatchesPreferredLanguage(resultLanguage, preferredLanguage));
+ }
+ }
+}
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<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ var queryHelpers = new Mock<IItemQueryHelpers>();
+ queryHelpers
+ .Setup(h => h.ApplyAccessFiltering(
+ It.IsAny<JellyfinDbContext>(),
+ It.IsAny<IQueryable<BaseItemEntity>>(),
+ It.IsAny<InternalItemsQuery>()))
+ .Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query);
+
_service = new ItemCountService(
factory.Object,
new Mock<IItemTypeLookup>().Object,
- new Mock<IItemQueryHelpers>().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