aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs16
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json6
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs2
-rw-r--r--Jellyfin.Api/Controllers/ArtistsController.cs64
-rw-r--r--Jellyfin.Api/Controllers/GenresController.cs9
-rw-r--r--Jellyfin.Api/Controllers/MusicGenresController.cs9
-rw-r--r--Jellyfin.Api/Controllers/StudiosController.cs9
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs2
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs4
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs153
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs6
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs40
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs92
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs146
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs8
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html28
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs142
18 files changed, 601 insertions, 137 deletions
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index c045f8558c..3db8265f6e 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1984,18 +1984,10 @@ namespace Emby.Server.Implementations.Library
{
// Playlists and BoxSets store their contents in LinkedChildren and never
// populate AncestorIds for those items, so a recursive AncestorIds query
- // would return zero rows. Resolve to the linked child IDs up front and
- // route through the existing indexed ItemIds filter.
- query.ItemIds = folder.LinkedChildren
- .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
- .Select(lc => lc.ItemId!.Value)
- .ToArray();
-
- // Empty linked-children should still return empty rather than scanning everything.
- if (query.ItemIds.Length == 0)
- {
- query.ItemIds = [Guid.NewGuid()];
- }
+ // would return zero rows. Filter by the descendant set instead, which follows
+ // the links and keeps descending, so a linked folder contributes what is below
+ // it as well - the episodes of a Series added to a collection, for example.
+ query.DescendantOfId = folder.Id;
}
else
{
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index dbfeabd88e..c41cedf98a 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -100,14 +100,14 @@
"TaskAudioNormalization": "Garso normalizavimas",
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
- "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
+ "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus",
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
- "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
+ "TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus",
"CleanupUserDataTask": "Naudotojo duomenų valymo užduotis",
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).",
- "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
+ "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}",
"NameExtraBehindTheScenes": "Užkulisiuose",
"NameExtraClip": "Klipas",
"NameExtraDeletedScene": "Ištrinta scena",
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
index afb27ddf9e..42835d7ad0 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
@@ -109,6 +109,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
var dupQuery = context.Peoples
.GroupBy(e => new { e.Name, e.PersonType })
.Where(e => e.Count() > 1)
+ .OrderBy(e => e.Key.Name)
+ .ThenBy(e => e.Key.PersonType)
.Select(e => e.Select(f => f.Id).ToArray());
var total = dupQuery.Count();
diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs
index f19ca77818..fdbbace1e7 100644
--- a/Jellyfin.Api/Controllers/ArtistsController.cs
+++ b/Jellyfin.Api/Controllers/ArtistsController.cs
@@ -126,6 +126,12 @@ public class ArtistsController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = null;
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
@@ -193,31 +199,7 @@ public class ArtistsController : BaseJellyfinApiController
var result = _libraryManager.GetArtists(query);
- var dtos = result.Items.Select(i =>
- {
- var (baseItem, itemCounts) = i;
- var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
- if (includeItemTypes.Length != 0)
- {
- dto.ChildCount = itemCounts.ItemCount;
- dto.ProgramCount = itemCounts.ProgramCount;
- dto.SeriesCount = itemCounts.SeriesCount;
- dto.EpisodeCount = itemCounts.EpisodeCount;
- dto.MovieCount = itemCounts.MovieCount;
- dto.TrailerCount = itemCounts.TrailerCount;
- dto.AlbumCount = itemCounts.AlbumCount;
- dto.SongCount = itemCounts.SongCount;
- dto.ArtistCount = itemCounts.ArtistCount;
- }
-
- return dto;
- });
-
- return new QueryResult<BaseItemDto>(
- query.StartIndex,
- result.TotalRecordCount,
- dtos.ToArray());
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
@@ -298,6 +280,12 @@ public class ArtistsController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = null;
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
@@ -365,31 +353,7 @@ public class ArtistsController : BaseJellyfinApiController
var result = _libraryManager.GetAlbumArtists(query);
- var dtos = result.Items.Select(i =>
- {
- var (baseItem, itemCounts) = i;
- var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
- if (includeItemTypes.Length != 0)
- {
- dto.ChildCount = itemCounts.ItemCount;
- dto.ProgramCount = itemCounts.ProgramCount;
- dto.SeriesCount = itemCounts.SeriesCount;
- dto.EpisodeCount = itemCounts.EpisodeCount;
- dto.MovieCount = itemCounts.MovieCount;
- dto.TrailerCount = itemCounts.TrailerCount;
- dto.AlbumCount = itemCounts.AlbumCount;
- dto.SongCount = itemCounts.SongCount;
- dto.ArtistCount = itemCounts.ArtistCount;
- }
-
- return dto;
- });
-
- return new QueryResult<BaseItemDto>(
- query.StartIndex,
- result.TotalRecordCount,
- dtos.ToArray());
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs
index 39c3f5abcf..18a8b67be2 100644
--- a/Jellyfin.Api/Controllers/GenresController.cs
+++ b/Jellyfin.Api/Controllers/GenresController.cs
@@ -97,6 +97,12 @@ public class GenresController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
@@ -143,8 +149,7 @@ public class GenresController : BaseJellyfinApiController
result = _libraryManager.GetGenres(query);
}
- var shouldIncludeItemTypes = includeItemTypes.Length != 0;
- return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs
index 7af44f8bd6..4ebf914895 100644
--- a/Jellyfin.Api/Controllers/MusicGenresController.cs
+++ b/Jellyfin.Api/Controllers/MusicGenresController.cs
@@ -98,6 +98,12 @@ public class MusicGenresController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
@@ -134,8 +140,7 @@ public class MusicGenresController : BaseJellyfinApiController
var result = _libraryManager.GetMusicGenres(query);
- var shouldIncludeItemTypes = includeItemTypes.Length != 0;
- return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs
index a8feb206a4..5bac850859 100644
--- a/Jellyfin.Api/Controllers/StudiosController.cs
+++ b/Jellyfin.Api/Controllers/StudiosController.cs
@@ -92,6 +92,12 @@ public class StudiosController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ // Asking for a type filter has always implied wanting that type's counts back.
+ if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
+ {
+ dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
+ }
+
User? user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
@@ -126,8 +132,7 @@ public class StudiosController : BaseJellyfinApiController
}
var result = _libraryManager.GetStudios(query);
- var shouldIncludeItemTypes = includeItemTypes.Length != 0;
- return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index e5df873f5b..c4851091c1 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -557,7 +557,7 @@ public class SubtitleController : BaseJellyfinApiController
if (!string.IsNullOrEmpty(fallbackFontPath))
{
var fontFile = _fileSystem.GetFiles(fallbackFontPath)
- .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+ .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
var fileSize = fontFile?.Length;
if (fontFile is not null && fileSize is not null && fileSize > 0)
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index d14c3a9343..4c5ed16015 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -156,7 +156,6 @@ public static class RequestHelpers
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result,
DtoOptions dtoOptions,
IDtoService dtoService,
- bool includeItemTypes,
User? user)
{
var dtos = result.Items.Select(i =>
@@ -164,7 +163,7 @@ public static class RequestHelpers
var (baseItem, counts) = i;
var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
- if (includeItemTypes)
+ if (counts is not null)
{
dto.ChildCount = counts.ItemCount;
dto.ProgramCount = counts.ProgramCount;
@@ -175,6 +174,7 @@ public static class RequestHelpers
dto.AlbumCount = counts.AlbumCount;
dto.SongCount = counts.SongCount;
dto.ArtistCount = counts.ArtistCount;
+ dto.MusicVideoCount = counts.MusicVideoCount;
}
return dto;
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 5a41619390..2ff8131ade 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -246,14 +246,20 @@ public sealed partial class BaseItemRepository
}
result.StartIndex = filter.StartIndex ?? 0;
- if (filter.IncludeItemTypes.Length > 0)
+ var page = query.AsEnumerable().Where(e => e is not null).ToList();
+
+ if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts))
{
- var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes);
+ var pageCleanNames = page
+ .Where(e => !string.IsNullOrEmpty(e.CleanName))
+ .Select(e => e.CleanName!)
+ .Distinct()
+ .ToList();
+
+ var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames);
result.Items =
[
- .. query
- .AsEnumerable()
- .Where(e => e is not null)
+ .. page
.Select(e =>
{
var item = DeserializeBaseItem(e, filter.SkipDeserialization);
@@ -268,9 +274,7 @@ public sealed partial class BaseItemRepository
{
result.Items =
[
- .. query
- .AsEnumerable()
- .Where(e => e != null)
+ .. page
.Select(e => DeserializeBaseItem(e, filter.SkipDeserialization))
.Where(item => item != null)
.Select(item => (item!, (ItemCounts?)null))
@@ -281,14 +285,22 @@ public sealed partial class BaseItemRepository
}
private Dictionary<string, ItemCounts> BuildItemCountsByCleanName(
- Database.Implementations.JellyfinDbContext context,
+ JellyfinDbContext context,
InternalItemsQuery filter,
- IReadOnlyList<ItemValueType> itemValueTypes)
+ IReadOnlyList<ItemValueType> itemValueTypes,
+ IReadOnlyList<string> cleanNames)
{
- var typeSubQuery = new InternalItemsQuery(filter.User)
+ var countsByCleanName = new Dictionary<string, ItemCounts>();
+ if (cleanNames.Count == 0)
+ {
+ return countsByCleanName;
+ }
+
+ // The counts describe everything the value is attached to, not only the types the list was
+ // filtered down to.
+ var scopeQuery = new InternalItemsQuery(filter.User)
{
ExcludeItemTypes = filter.ExcludeItemTypes,
- IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ExcludeItemIds = filter.ExcludeItemIds,
@@ -298,33 +310,51 @@ public sealed partial class BaseItemRepository
IsPlayed = filter.IsPlayed
};
- var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
- .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
+ var scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery);
+ var valueLinks = context.ItemValuesMap
+ .AsNoTracking()
+ .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
+ .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
+ var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo];
+ var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram];
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
- var itemIds = itemCountQuery.Select(e => e.Id);
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems.
- var rawCounts = context.ItemValuesMap
- .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
- .Where(ivm => itemIds.Contains(ivm.ItemId))
+ var rawCounts = valueLinks
.Join(
- context.BaseItems,
+ scopedItems,
ivm => ivm.ItemId,
e => e.Id,
- (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
- .GroupBy(x => new { x.CleanName, x.Type })
- .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
- .AsEnumerable();
+ (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId })
+ .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId })
+ .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() })
+ .ToList();
+
+ // Only studios and genres pass down from a series to its episodes; an artist credit does not.
+ var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre);
+ var episodeCounts = inheritsToEpisodes
+ ? BuildEpisodeCountsByCleanName(
+ scopedItems,
+ valueLinks,
+ rawCounts
+ .Where(x => x.Type == episodeTypeName)
+ .Select(x => (x.CleanName, x.SeriesId, x.Count))
+ .ToList(),
+ seriesTypeName,
+ episodeTypeName)
+ : rawCounts
+ .Where(x => x.Type == episodeTypeName)
+ .GroupBy(x => x.CleanName)
+ .ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
- var countsByCleanName = new Dictionary<string, ItemCounts>();
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
{
var counts = new ItemCounts();
@@ -334,10 +364,6 @@ public sealed partial class BaseItemRepository
{
counts.SeriesCount += row.Count;
}
- else if (row.Type == episodeTypeName)
- {
- counts.EpisodeCount += row.Count;
- }
else if (row.Type == movieTypeName)
{
counts.MovieCount += row.Count;
@@ -350,6 +376,14 @@ public sealed partial class BaseItemRepository
{
counts.ArtistCount += row.Count;
}
+ else if (row.Type == musicVideoTypeName)
+ {
+ counts.MusicVideoCount += row.Count;
+ }
+ else if (row.Type == programTypeName)
+ {
+ counts.ProgramCount += row.Count;
+ }
else if (row.Type == audioTypeName)
{
counts.SongCount += row.Count;
@@ -360,9 +394,72 @@ public sealed partial class BaseItemRepository
}
}
+ // Episodes are counted separately: the value is usually only written on the series.
+ counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key);
+ counts.ItemCount = counts.TotalItemCount();
countsByCleanName[group.Key] = counts;
}
+ // A value carried by nothing but the episodes below a tagged series has no row of its own.
+ foreach (var (cleanName, episodeCount) in episodeCounts)
+ {
+ if (!countsByCleanName.ContainsKey(cleanName))
+ {
+ countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount };
+ }
+ }
+
return countsByCleanName;
}
+
+ private static Dictionary<string, int> BuildEpisodeCountsByCleanName(
+ IQueryable<BaseItemEntity> scopedItems,
+ IQueryable<ItemValueMap> valueLinks,
+ IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes,
+ string seriesTypeName,
+ string episodeTypeName)
+ {
+ // Resolved in steps rather than as one union: each of these drives off an index, while the
+ // single-statement form leaves SQLite free to scan every episode in the library instead.
+ var taggedSeries = valueLinks
+ .Join(
+ scopedItems.Where(e => e.Type == seriesTypeName),
+ ivm => ivm.ItemId,
+ e => e.Id,
+ (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id })
+ .ToList();
+
+ var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray();
+ var episodesPerSeries = seriesIds.Length == 0
+ ? []
+ : scopedItems
+ .Where(e => e.Type == episodeTypeName && e.SeriesId != null)
+ .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value)
+ .GroupBy(e => e.SeriesId!.Value)
+ .Select(g => new { SeriesId = g.Key, Count = g.Count() })
+ .ToDictionary(x => x.SeriesId, x => x.Count);
+
+ var episodeCounts = new Dictionary<string, int>();
+ var seriesByCleanName = new Dictionary<string, HashSet<Guid>>();
+ foreach (var group in taggedSeries.GroupBy(x => x.CleanName))
+ {
+ var series = group.Select(x => x.SeriesId).ToHashSet();
+ seriesByCleanName[group.Key] = series;
+ episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id));
+ }
+
+ foreach (var (cleanName, seriesId, count) in taggedEpisodes)
+ {
+ if (seriesId is not null
+ && seriesByCleanName.TryGetValue(cleanName, out var series)
+ && series.Contains(seriesId.Value))
+ {
+ continue;
+ }
+
+ episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count;
+ }
+
+ return episodeCounts;
+ }
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 1e30f0164e..d635b38df5 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -1091,6 +1091,12 @@ public sealed partial class BaseItemRepository
baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter));
}
+ if (filter.DescendantOfId.HasValue)
+ {
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value);
+ baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id));
+ }
+
if (filter.LinkedChildAncestorIds.Length > 0)
{
// Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids.
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index 14b120363f..704dc31fd0 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -249,11 +249,51 @@ public class ItemCountService : IItemCountService
}
}
+ if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
+ && relatedItemKinds.Contains(BaseItemKind.Episode)
+ && relatedItemKinds.Contains(BaseItemKind.Series))
+ {
+ var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
+ totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
+ result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
+ }
+
result.ItemCount = totalCount;
return result;
}
+ private int CountEpisodesOfTaggedSeries(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> taggedItems,
+ InternalItemsQuery accessFilter,
+ out int unrelatedEpisodeCount)
+ {
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
+
+ var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id);
+ unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName
+ && (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value)));
+
+ // Materialised so the episode count drives off IX_BaseItems_SeriesId.
+ var seriesIds = taggedItems
+ .Where(e => e.Type == seriesTypeName)
+ .Select(e => e.Id)
+ .ToArray();
+
+ if (seriesIds.Length == 0)
+ {
+ return 0;
+ }
+
+ var episodes = context.BaseItems.AsNoTracking()
+ .Where(e => e.Type == episodeTypeName && e.SeriesId != null)
+ .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value);
+
+ return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count();
+ }
+
private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
=> context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
index 4a6e74c229..8f239007d8 100644
--- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
@@ -22,6 +22,11 @@ namespace Jellyfin.Server.Migrations.Routines;
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
{
+ private const int ParseProgressLogStep = 25_000;
+ private const int FileCheckProgressLogStep = 10_000;
+ private const int ResolveProgressLogStep = 10_000;
+ private const int DeleteProgressLogStep = 25;
+
private readonly ILogger<MigrateLinkedChildren> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly ILibraryManager _libraryManager;
@@ -85,7 +90,6 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var droppedChildren = 0;
var linkedChildrenToAdd = new List<LinkedChildEntity>();
var processedCount = 0;
- const int progressLogStep = 1000;
var totalItems = itemsWithData.Count;
foreach (var item in itemsWithData)
@@ -95,7 +99,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
continue;
}
- if (processedCount > 0 && processedCount % progressLogStep == 0)
+ if (processedCount > 0 && processedCount % ParseProgressLogStep == 0)
{
_logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems);
}
@@ -311,11 +315,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
- var itemsToDelete = wrongTypeChildIds
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- var deleted = DeleteItems(itemsToDelete!);
+ var deleted = ResolveAndDeleteItems(wrongTypeChildIds, "wrong-type alternate version items");
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted);
}
@@ -342,11 +342,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count);
- var itemsToDelete = orphanedVersionIds
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- var deleted = DeleteItems(itemsToDelete!);
+ var deleted = ResolveAndDeleteItems(orphanedVersionIds, "orphaned alternate version BaseItems");
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
}
@@ -371,11 +367,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
- var itemsToDelete = orphanedIds
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- var deleted = DeleteItems(itemsToDelete!);
+ var deleted = ResolveAndDeleteItems(orphanedIds, "items from deleted libraries");
_logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
}
@@ -427,8 +419,23 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var skippedUnrootedItems = 0;
var staleIds = new List<Guid>();
+ var checkedCount = 0;
+ _logger.LogInformation("Checking {Total} items for missing files.", itemsWithPaths.Count);
+
foreach (var item in itemsWithPaths)
{
+ // A miss on offline storage can block for the mount timeout, so report while scanning.
+ if (checkedCount > 0 && checkedCount % FileCheckProgressLogStep == 0)
+ {
+ _logger.LogInformation(
+ "Checking for missing files: {Checked}/{Total} items, {Stale} stale so far.",
+ checkedCount,
+ itemsWithPaths.Count,
+ staleIds.Count);
+ }
+
+ checkedCount++;
+
// Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
var path = _appHost.ExpandVirtualPath(item.Path!);
@@ -482,16 +489,47 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
- var itemsToDelete = staleIds
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- var deleted = DeleteItems(itemsToDelete!);
+ var deleted = ResolveAndDeleteItems(staleIds, "items with missing files");
_logger.LogInformation("Removed {Count} stale items.", deleted);
}
- private int DeleteItems(IReadOnlyCollection<BaseItem> items)
+ private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description)
+ {
+ if (ids.Count == 0)
+ {
+ return 0;
+ }
+
+ return DeleteItems(ResolveItems(ids, description), description);
+ }
+
+ private List<BaseItem> ResolveItems(IReadOnlyCollection<Guid> ids, string description)
+ {
+ // Each lookup is a separate repository read; cached ones are fast, so this only reports
+ // once a set is large enough for the reads to add up to a noticeable stretch.
+ var items = new List<BaseItem>(ids.Count);
+ var processed = 0;
+ foreach (var id in ids)
+ {
+ if (processed > 0 && processed % ResolveProgressLogStep == 0)
+ {
+ _logger.LogInformation("Loading {Description}: {Processed}/{Total} items", description, processed, ids.Count);
+ }
+
+ processed++;
+
+ var item = _libraryManager.GetItemById(id);
+ if (item is not null)
+ {
+ items.Add(item);
+ }
+ }
+
+ return items;
+ }
+
+ private int DeleteItems(IReadOnlyCollection<BaseItem> items, string description)
{
if (items.Count == 0)
{
@@ -500,8 +538,16 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
var deleted = 0;
+ var processed = 0;
foreach (var item in items)
{
+ if (processed > 0 && processed % DeleteProgressLogStep == 0)
+ {
+ _logger.LogInformation("Removing {Description}: {Processed}/{Total} items", description, processed, items.Count);
+ }
+
+ processed++;
+
try
{
_libraryManager.DeleteItem(item, options);
diff --git a/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs
new file mode 100644
index 0000000000..e665725ced
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.Migrations.Stages;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Enables the local similarity providers on libraries that predate the similar items settings.
+/// </summary>
+[JellyfinMigration("2026-08-31T10:00:00", nameof(EnableLocalSimilarityProviders), Stage = JellyfinMigrationStageTypes.AppInitialisation)]
+internal class EnableLocalSimilarityProviders : IAsyncMigrationRoutine
+{
+ private readonly ILibraryManager _libraryManager;
+ private readonly IProviderManager _providerManager;
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="EnableLocalSimilarityProviders"/> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="providerManager">The provider manager.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI integration.</param>
+ /// <param name="logger">The logger.</param>
+ public EnableLocalSimilarityProviders(
+ ILibraryManager libraryManager,
+ IProviderManager providerManager,
+ IStartupLogger<EnableLocalSimilarityProviders> startupLogger,
+ ILogger<EnableLocalSimilarityProviders> logger)
+ {
+ _libraryManager = libraryManager;
+ _providerManager = providerManager;
+ _logger = startupLogger.With(logger);
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ // Libraries created before similar items became configurable have an empty provider list,
+ // which the library editor renders as "everything unchecked" instead of falling back to the
+ // defaults it uses for new libraries. Seed the local providers so they stay enabled.
+ var localProvidersByType = GetLocalProvidersByItemType();
+ if (localProvidersByType.Count == 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ foreach (var virtualFolder in _libraryManager.GetVirtualFolders(false))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ EnableLocalProviders(virtualFolder, localProvidersByType);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void EnableLocalProviders(VirtualFolderInfo virtualFolder, Dictionary<string, string[]> localProvidersByType)
+ {
+ var options = virtualFolder.LibraryOptions;
+ if (options?.TypeOptions is null || options.TypeOptions.Length == 0)
+ {
+ return;
+ }
+
+ // Some virtual folders don't have a proper item id.
+ if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
+ {
+ return;
+ }
+
+ var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
+ if (collectionFolder is null)
+ {
+ _logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
+ return;
+ }
+
+ var changed = false;
+ foreach (var typeOptions in options.TypeOptions)
+ {
+ changed |= EnableLocalProviders(typeOptions, localProvidersByType, virtualFolder.Name);
+ }
+
+ if (changed)
+ {
+ collectionFolder.UpdateLibraryOptions(options);
+ }
+ }
+
+ private bool EnableLocalProviders(TypeOptions typeOptions, Dictionary<string, string[]> localProvidersByType, string libraryName)
+ {
+ if (typeOptions.Type is null || !localProvidersByType.TryGetValue(typeOptions.Type, out var localProviders))
+ {
+ return false;
+ }
+
+ var enabled = typeOptions.SimilarItemProviders ?? [];
+ var missing = localProviders.Where(name => !enabled.Contains(name, StringComparer.OrdinalIgnoreCase)).ToArray();
+ if (missing.Length == 0)
+ {
+ return false;
+ }
+
+ // Local providers rank ahead of remote ones, and the enabled list doubles as the
+ // priority order when no explicit order was saved.
+ typeOptions.SimilarItemProviders = [.. missing, .. enabled];
+ if (typeOptions.SimilarItemProviderOrder is { Length: > 0 } order)
+ {
+ typeOptions.SimilarItemProviderOrder = [.. missing, .. order];
+ }
+
+ _logger.LogInformation("Enabled local similarity providers {Providers} for '{ItemType}' in library '{LibraryName}'.", missing, typeOptions.Type, libraryName);
+ return true;
+ }
+
+ private Dictionary<string, string[]> GetLocalProvidersByItemType()
+ {
+ var result = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var summary in _providerManager.GetAllMetadataPlugins())
+ {
+ var names = summary.Plugins
+ .Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider)
+ .Select(p => p.Name)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (names.Length > 0)
+ {
+ result[summary.ItemType] = names;
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index e85f86b72f..0e5a5047cd 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -103,6 +103,7 @@ namespace MediaBrowser.Controller.Entities
|| SubtitleLanguages.Count > 0
|| LinkedChildAncestorIds.Length > 0
|| AncestorIds.Length > 0
+ || DescendantOfId.HasValue
|| IsFavorite.HasValue
|| IsFavoriteOrLiked.HasValue
|| IsLiked.HasValue
@@ -368,6 +369,13 @@ namespace MediaBrowser.Controller.Entities
/// </summary>
public Guid[] LinkedChildAncestorIds { get; set; }
+ /// <summary>
+ /// Gets or sets the id of a folder whose descendants the items must be part of.
+ /// Unlike <see cref="AncestorIds"/> this also follows the linked children of BoxSets and
+ /// Playlists, so it reaches the items below a linked folder (a Series' episodes, for example).
+ /// </summary>
+ public Guid? DescendantOfId { get; set; }
+
public Guid[] TopParentIds { get; set; }
public CollectionType?[] PresetViews { get; set; }
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html
index dec21d1b42..d485fe555a 100644
--- a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html
@@ -7,7 +7,6 @@
<div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select">
<div data-role="content">
<div class="content-primary">
- <img id="listenBrainzLogo" alt="ListenBrainz" style="max-width:240px;display:block;margin:0 auto 1em;" />
<h1>ListenBrainz</h1>
<p>Get similar artist recommendations from ListenBrainz Labs.</p>
<form class="configForm">
@@ -18,12 +17,12 @@
<div class="selectContainer">
<label class="selectLabel" for="algorithm">Similarity Algorithm</label>
<select is="emby-select" id="algorithm" class="emby-select-withcolor">
- <option value="0" selected>~5 years / 1825 days (Recommended)</option>
- <option value="1">~5 years / 1800 days</option>
- <option value="2">~20 years / 7500 days</option>
- <option value="3">~20 years / 7500 days (high contribution)</option>
- <option value="4">~25 years / 9000 days</option>
- <option value="5">~75 days (recent)</option>
+ <option value="SessionBased1825Days" selected>~5 years / 1825 days (Recommended)</option>
+ <option value="SessionBased1800Days">~5 years / 1800 days</option>
+ <option value="SessionBased7500Days">~20 years / 7500 days</option>
+ <option value="SessionBased7500DaysHighContribution">~20 years / 7500 days (high contribution)</option>
+ <option value="SessionBased9000Days">~25 years / 9000 days</option>
+ <option value="SessionBased75Days">~75 days (recent)</option>
</select>
<div class="fieldDescription">The algorithm used for artist similarity calculation.</div>
</div>
@@ -52,13 +51,14 @@
</div>
<script type="text/javascript">
var ListenBrainzPluginConfig = {
- uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e"
+ uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e",
+ defaultAlgorithm: "SessionBased1825Days"
};
document.querySelector('.configPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
- document.querySelector('#listenBrainzLogo').src = ApiClient.getUrl('web/ConfigurationPage', { name: 'ListenBrainzLogo' });
+
ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) {
var labsServer = document.querySelector('#labsServer');
labsServer.value = config.LabsServer;
@@ -67,7 +67,13 @@
cancelable: false
}));
- document.querySelector('#algorithm').value = config.Algorithm;
+ // The API serialises the algorithm as its enum name, so an unknown value here
+ // means a config written by an older build; fall back to the default.
+ var algorithm = document.querySelector('#algorithm');
+ algorithm.value = config.Algorithm;
+ if (!algorithm.value) {
+ algorithm.value = ListenBrainzPluginConfig.defaultAlgorithm;
+ }
var rateLimit = document.querySelector('#rateLimit');
rateLimit.value = config.RateLimit;
@@ -93,7 +99,7 @@
ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) {
config.LabsServer = document.querySelector('#labsServer').value;
- config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10);
+ config.Algorithm = document.querySelector('#algorithm').value;
config.RateLimit = document.querySelector('#rateLimit').value;
config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10);
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
index b3a67189bb..c94a6455bc 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
@@ -128,6 +128,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <summary>
/// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching.
/// </summary>
- public int SimilarItemsCacheDays { get; set; } = 7;
+ public int SimilarItemsCacheDays { get; set; } = 90;
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs
new file mode 100644
index 0000000000..0ca11eb58d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a
+/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a
+/// linked folder are only reachable by following the link and then the ancestor chain.
+/// </summary>
+public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture
+{
+ private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
+ private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
+ private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
+ private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+
+ private readonly BaseItemRepository _repository;
+
+ private readonly Guid _library = Guid.NewGuid();
+ private readonly Guid _collection = Guid.NewGuid();
+ private readonly Guid _series = Guid.NewGuid();
+ private readonly Guid _season = Guid.NewGuid();
+ private readonly Guid _episode = Guid.NewGuid();
+
+ // A movie the collection links directly, so the direct-child case is covered alongside the nested one.
+ private readonly Guid _collectionMovie = Guid.NewGuid();
+
+ // In the same library but outside the collection, as the control the assertions are read against.
+ private readonly Guid _otherSeries = Guid.NewGuid();
+ private readonly Guid _otherEpisode = Guid.NewGuid();
+
+ public BaseItemRepositoryDescendantFilterTests()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ Seed(ctx);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void DescendantOfId_ReachesEpisodesOfALinkedSeries()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ DescendantOfId = _collection,
+ IncludeItemTypes = [BaseItemKind.Episode]
+ });
+
+ Assert.Equal([_episode], ids);
+ }
+
+ [Fact]
+ public void DescendantOfId_ReturnsEveryLevelBelowTheCollection()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet();
+
+ Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order());
+ }
+
+ [Fact]
+ public void DescendantOfId_KeepsDirectlyLinkedChildren()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ DescendantOfId = _collection,
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal([_collectionMovie], ids);
+ }
+
+ [Fact]
+ public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() });
+
+ Assert.Empty(ids);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" });
+
+ // AncestorIds is a closure: production writes one row per ancestor, not just the parent.
+ AddAncestors(context, _series, _library);
+ AddAncestors(context, _season, _series, _library);
+ AddAncestors(context, _episode, _season, _series, _library);
+ AddAncestors(context, _collectionMovie, _library);
+ AddAncestors(context, _otherSeries, _library);
+ AddAncestors(context, _otherEpisode, _otherSeries, _library);
+
+ AddLink(context, _series, 0);
+ AddLink(context, _collectionMovie, 1);
+
+ context.SaveChanges();
+ }
+
+ private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
+ {
+ foreach (var ancestorId in ancestorIds)
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = ancestorId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+
+ private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder)
+ {
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _collection,
+ ChildId = childId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = sortOrder
+ });
+ }
+}