aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs3
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs65
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs2
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs6
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs10
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs19
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs38
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs32
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs334
-rw-r--r--MediaBrowser.Controller/Entities/Audio/MusicArtist.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Audio/MusicGenre.cs5
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs41
-rw-r--r--MediaBrowser.Controller/Entities/Genre.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Person.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Studio.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Year.cs5
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs6
-rw-r--r--MediaBrowser.Controller/Persistence/IPeopleRepository.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs103
-rwxr-xr-xMediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs41
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs97
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs2
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs52
-rw-r--r--tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs81
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs105
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs189
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs73
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs2
29 files changed, 1224 insertions, 115 deletions
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index 295efd456c..84d50f5121 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -107,7 +107,8 @@ namespace Emby.Server.Implementations.Collections
SaveLocalMetadata = true
};
- var name = _localizationManager.GetLocalizedString("Collections");
+ // This names a library for the whole server, so ignore the requesting client's language.
+ var name = _localizationManager.GetServerLocalizedString("Collections");
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 2bba659a23..dd8c883684 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -2527,9 +2527,15 @@ namespace Emby.Server.Implementations.Library
}
}
- if (!File.Exists(image.Path))
- {
- _logger.LogWarning("Image not found at {ImagePath}", image.Path);
+ if (string.IsNullOrEmpty(image.Path) || !File.Exists(image.Path))
+ {
+ _logger.LogWarning(
+ "{ImageType} image for {ItemName} ({ItemId}) not found at \"{ImagePath}\", source was {SourcePath}",
+ img.Type,
+ item.Name,
+ item.Id,
+ image.Path,
+ img.Path);
continue;
}
@@ -2927,7 +2933,8 @@ namespace Emby.Server.Implementations.Library
"views",
_fileSystem.GetValidFilename(viewType.ToString()));
- var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView));
+ // The display name is localized, so it must not take part in the id.
+ var id = GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView));
var item = GetItemById(id) as UserView;
@@ -2951,6 +2958,13 @@ namespace Emby.Server.Implementations.Library
refresh = true;
}
+ else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
+ {
+ item.Name = name;
+ item.ForcedSortName = sortName;
+
+ refresh = true;
+ }
if (refresh)
{
@@ -2971,7 +2985,9 @@ namespace Emby.Server.Implementations.Library
var parentIdString = parentId.IsEmpty()
? null
: parentId.ToString("N", CultureInfo.InvariantCulture);
- var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
+
+ // The name is either localized (grouped views) or the library folder's own name.
+ var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
var id = GetNewItemId(idValues, typeof(UserView));
@@ -3001,6 +3017,11 @@ namespace Emby.Server.Implementations.Library
isNew = true;
}
+ else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
+ {
+ item.Name = name;
+ item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
+ }
var lastRefreshedUtc = item.DateLastRefreshed;
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
@@ -3102,7 +3123,7 @@ namespace Emby.Server.Implementations.Library
var parentIdString = parentId.IsEmpty()
? null
: parentId.ToString("N", CultureInfo.InvariantCulture);
- var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
+ var idValues = "37_namedview_" + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
if (!string.IsNullOrEmpty(uniqueId))
{
idValues += uniqueId;
@@ -3136,9 +3157,10 @@ namespace Emby.Server.Implementations.Library
isNew = true;
}
- if (viewType != item.ViewType)
+ if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal))
{
item.ViewType = viewType;
+ item.Name = name;
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
}
@@ -3559,6 +3581,12 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc/>
+ public int DeleteOrphanedCredits()
+ {
+ return _peopleRepository.DeleteOrphanedCredits();
+ }
+
+ /// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
@@ -3603,7 +3631,20 @@ namespace Emby.Server.Implementations.Library
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
- return item.GetImageInfo(image.Type, imageIndex);
+ var localImage = item.GetImageInfo(image.Type, imageIndex);
+ if (localImage is null)
+ {
+ throw new InvalidOperationException(string.Format(
+ CultureInfo.InvariantCulture,
+ "Downloaded {0} image {1} from {2} is not attached to {3} ({4})",
+ image.Type,
+ imageIndex,
+ url,
+ item.Name,
+ item.Id));
+ }
+
+ return localImage;
}
catch (HttpRequestException ex)
{
@@ -3625,7 +3666,13 @@ namespace Emby.Server.Implementations.Library
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
}
- throw new InvalidOperationException("Unable to convert any images to local");
+ throw new InvalidOperationException(string.Format(
+ CultureInfo.InvariantCulture,
+ "Unable to convert any {0} image url in \"{1}\" to a local file for {2} ({3})",
+ image.Type,
+ image.Path,
+ item.Name,
+ item.Id));
}
public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary)
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
index 6e9a38fd34..6624d0125f 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
@@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
args.LibraryOptions.SeasonZeroDisplayName :
string.Format(
CultureInfo.InvariantCulture,
- _localization.GetLocalizedString("NameSeasonNumber"),
+ _localization.GetServerLocalizedString("NameSeasonNumber"),
seasonNumber,
args.LibraryOptions.PreferredMetadataLanguage);
}
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index 49d76e195d..47b3891901 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library
if (_config.Configuration.EnableFolderView)
{
- var name = _localizationManager.GetLocalizedString("Folders");
+ var name = _localizationManager.GetServerLocalizedString("Folders");
list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty));
}
@@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library
public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName)
{
- var name = _localizationManager.GetLocalizedString(localizationKey);
+ var name = _localizationManager.GetServerLocalizedString(localizationKey);
return GetUserSubViewWithName(name, parentId, type, sortName);
}
@@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library
return GetUserView((Folder)parents[0], viewType, string.Empty);
}
- var name = _localizationManager.GetLocalizedString(localizationKey);
+ var name = _localizationManager.GetServerLocalizedString(localizationKey);
return _libraryManager.GetNamedView(user, name, viewType, sortName);
}
diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
index dacef102dd..078a0b921d 100644
--- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
@@ -49,6 +49,14 @@ public class PeopleValidator
/// <returns>Task.</returns>
public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
{
+ // Before the refresh below walks them: a credit no item maps to any more stands for nothing,
+ // and while it is there the person it names cannot reach the dead-person sweep either.
+ var numOrphaned = _libraryManager.DeleteOrphanedCredits();
+ if (numOrphaned > 0)
+ {
+ _logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
+ }
+
var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
var numComplete = 0;
@@ -115,6 +123,6 @@ public class PeopleValidator
progress.Report(100);
- _logger.LogInformation("People validation complete");
+ _logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned);
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index 05ff720ddf..c0067d8392 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
+using Jellyfin.Server.Implementations.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
@@ -323,10 +324,21 @@ public sealed partial class BaseItemRepository
orderedQuery = query.OrderBy(relevanceExpression);
}
+ // Folders carry no played flag of their own, so these two keys go through the same predicate
+ // the isPlayed filter uses rather than through the stored-column lookup in OrderMapper.
+ Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy) => sortBy switch
+ {
+ ItemSortBy.IsPlayed when filter.User is not null
+ => AsOrderKey(BuildIsPlayedFilter(context, filter.User)),
+ ItemSortBy.IsUnplayed when filter.User is not null
+ => AsOrderKey(BuildIsPlayedFilter(context, filter.User).Not()),
+ _ => OrderMapper.MapOrderByField(sortBy, filter, context)
+ };
+
if (orderBy.Length > 0)
{
var firstOrdering = orderBy[0];
- var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
+ var expression = MapOrderByField(firstOrdering.OrderBy);
if (orderedQuery is null)
{
@@ -350,7 +362,7 @@ public sealed partial class BaseItemRepository
foreach (var item in orderBy.Skip(1))
{
- expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
+ expression = MapOrderByField(item.OrderBy);
orderedQuery = item.SortOrder == SortOrder.Ascending
? orderedQuery.ThenBy(expression)
: orderedQuery.ThenByDescending(expression);
@@ -666,6 +678,9 @@ public sealed partial class BaseItemRepository
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
}
+ private static Expression<Func<BaseItemEntity, object?>> AsOrderKey(Expression<Func<BaseItemEntity, bool>> predicate)
+ => Expression.Lambda<Func<BaseItemEntity, object?>>(Expression.Convert(predicate.Body, typeof(object)), predicate.Parameters);
+
/// <inheritdoc />
public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
{
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 623c1ea0ab..1e30f0164e 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -35,6 +35,27 @@ public sealed partial class BaseItemRepository
// instance across several lambdas, and this filter is combined into a tree more than once.
private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
+ // Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree.
+ private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user)
+ {
+ var userId = user.Id;
+
+ // Leaf items carry their own played state.
+ var playedItemIds = context.UserData
+ .Where(ud => ud.UserId == userId && ud.Played)
+ .Select(ud => ud.ItemId);
+
+ // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
+ // descendant is left unplayed, matching what the DTO reports for them. This has to key off
+ // the item itself rather than off the requested item types: tag and collection listings mix
+ // folders and leaf items in a single query.
+ var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
+ .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+
+ return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
+ .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
+ }
+
// "und" is the language filters' stand-in for a track that declares no language at all.
private static string NormalizeLanguage(string language)
=> string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language;
@@ -523,22 +544,7 @@ public sealed partial class BaseItemRepository
if (filter.IsPlayed.HasValue)
{
- var userId = filter.User!.Id;
-
- // Leaf items carry their own played state.
- var playedItemIds = context.UserData
- .Where(ud => ud.UserId == userId && ud.Played)
- .Select(ud => ud.ItemId);
-
- // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
- // descendant is left unplayed, matching what the DTO reports for them. This has to key off
- // the item itself rather than off the requested item types: tag and collection listings mix
- // folders and leaf items in a single query.
- var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!)
- .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
-
- var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
- .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
+ var isPlayedFilter = BuildIsPlayedFilter(context, filter.User!);
baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not());
}
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index aaa363b046..da2ad033ec 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -194,13 +194,45 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
listOrder++;
}
+ var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray();
context.PeopleBaseItemMap.RemoveRange(existingMaps);
context.SaveChanges();
+
+ // Nothing else ever deletes a credit row, so one left without a single mapping outlives the
+ // credit it stood for: it keeps a person of that name off the dead-person sweep, which only
+ // sees items no credit names, and keeps the name in every by-name list. That is how a credit
+ // a provider dropped, or one a broken provider result invented, becomes impossible to clean up.
+ DeleteCreditsWithoutMapping(context, droppedCredits);
+
+ context.SaveChanges();
transaction.Commit();
}
/// <inheritdoc/>
+ public int DeleteOrphanedCredits()
+ {
+ using var context = _dbProvider.CreateDbContext();
+
+ return DeleteCreditsWithoutMapping(context, null);
+ }
+
+ // A null candidate list sweeps every credit, anything else only the ones just unmapped.
+ private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList<Guid>? candidates)
+ {
+ if (candidates is not null && candidates.Count == 0)
+ {
+ return 0;
+ }
+
+ var credits = candidates is null
+ ? context.Peoples.AsQueryable()
+ : context.Peoples.WhereOneOrMany(candidates, e => e.Id);
+
+ return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete();
+ }
+
+ /// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
using var context = _dbProvider.CreateDbContext();
diff --git a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs
new file mode 100644
index 0000000000..3fc2387e09
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs
@@ -0,0 +1,334 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Moves the views whose id used to be derived from their localized name onto their name independent id.
+/// </summary>
+[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger<ConsolidateLocalizedUserViews> _logger;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _configurationManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ConsolidateLocalizedUserViews"/> class.
+ /// </summary>
+ /// <param name="logger">The startup logger.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="configurationManager">The server configuration manager.</param>
+ /// <param name="fileSystem">The file system.</param>
+ /// <param name="dbProvider">The database context factory.</param>
+ public ConsolidateLocalizedUserViews(
+ IStartupLogger<ConsolidateLocalizedUserViews> logger,
+ ILibraryManager libraryManager,
+ IServerConfigurationManager configurationManager,
+ IFileSystem fileSystem,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
+ {
+ _logger = logger;
+ _libraryManager = libraryManager;
+ _configurationManager = configurationManager;
+ _fileSystem = fileSystem;
+ _dbProvider = dbProvider;
+ }
+
+ /// <inheritdoc />
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ // The Live TV view is the one that hurts: every channel and program is parented to it, so a
+ // translation update or a change of UI culture used to leave them behind under a view nothing
+ // looks up any more.
+ var views = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.UserView]
+ }).OfType<UserView>().Where(view => view.ViewType.HasValue).ToArray();
+
+ if (views.Length == 0)
+ {
+ return;
+ }
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ foreach (var group in views.GroupBy(view => view.ViewType!.Value))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var viewType = group.Key;
+ var folderName = _fileSystem.GetValidFilename(viewType.ToString());
+ var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName);
+
+ // Only the views created for a view type as a whole are named after it. The per user and
+ // per parent ones get a folder of their own, and carry no children to lose. Match on the
+ // folder rather than the whole path so a metadata directory that has since moved still
+ // lines up.
+ var candidates = group
+ .Where(view => !string.IsNullOrEmpty(view.Path)
+ && string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ if (candidates.Length == 0)
+ {
+ continue;
+ }
+
+ // Mirrors LibraryManager.GetNamedView(name, viewType, sortName).
+ var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView));
+
+ var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray();
+ if (stale.Length == 0)
+ {
+ continue;
+ }
+
+ await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task ConsolidateAsync(
+ JellyfinDbContext dbContext,
+ CollectionType viewType,
+ string path,
+ Guid canonicalId,
+ IReadOnlyList<UserView> candidates,
+ IReadOnlyList<UserView> stale,
+ CancellationToken cancellationToken)
+ {
+ var staleIds = stale.Select(view => view.Id).ToArray();
+ Guid? newParentId = canonicalId;
+ var sourceId = Guid.Empty;
+
+ if (!candidates.Any(view => view.Id.Equals(canonicalId)))
+ {
+ // Whichever of the old views the items ended up under is the one worth keeping, so give the
+ // canonical id a copy of it.
+ var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false);
+ sourceId = source.Id;
+
+ _libraryManager.CreateItem(
+ new UserView
+ {
+ Path = path,
+ Id = canonicalId,
+ DateCreated = source.DateCreated,
+ DateModified = source.DateModified,
+ Name = source.Name,
+ ViewType = viewType,
+ ForcedSortName = source.ForcedSortName
+ },
+ null);
+ }
+
+ var reparented = await dbContext.BaseItems
+ .Where(e => e.ParentId.HasValue)
+ .WhereOneOrMany(staleIds, e => e.ParentId!.Value)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.BaseItems
+ .Where(e => e.TopParentId.HasValue)
+ .WhereOneOrMany(staleIds, e => e.TopParentId!.Value)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken)
+ .ConfigureAwait(false);
+
+ await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false);
+ await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false);
+
+ // Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last.
+ await dbContext.BaseItems
+ .WhereOneOrMany(staleIds, e => e.Id)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}",
+ reparented,
+ staleIds.Length,
+ viewType,
+ canonicalId);
+ }
+
+ private async Task<UserView> PickSourceAsync(
+ JellyfinDbContext dbContext,
+ IReadOnlyList<UserView> stale,
+ IReadOnlyList<Guid> staleIds,
+ CancellationToken cancellationToken)
+ {
+ var childCounts = await dbContext.BaseItems
+ .Where(e => e.ParentId.HasValue)
+ .WhereOneOrMany(staleIds, e => e.ParentId!.Value)
+ .GroupBy(e => e.ParentId!.Value)
+ .Select(g => new { ParentId = g.Key, Count = g.Count() })
+ .ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken)
+ .ConfigureAwait(false);
+
+ return stale
+ .OrderByDescending(view => childCounts.GetValueOrDefault(view.Id))
+ .ThenBy(view => view.DateCreated)
+ .First();
+ }
+
+ private static async Task MoveUserSettingsAsync(
+ JellyfinDbContext dbContext,
+ Guid canonicalId,
+ Guid sourceId,
+ IReadOnlyList<Guid> staleIds,
+ CancellationToken cancellationToken)
+ {
+ // Everything below is keyed by the view's id, and a view holding no children still holds the
+ // ordering it was given and whether it was hidden. Only the view that was promoted can hand
+ // those over - the rest would collide on the one row per user, item and client - so the others
+ // are dropped instead.
+ var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray();
+
+ if (!sourceId.Equals(Guid.Empty))
+ {
+ var moved = new[] { sourceId };
+
+ await dbContext.DisplayPreferences
+ .WhereOneOrMany(moved, e => e.ItemId)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.ItemDisplayPreferences
+ .WhereOneOrMany(moved, e => e.ItemId)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.CustomItemDisplayPreferences
+ .WhereOneOrMany(moved, e => e.ItemId)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (dropped.Length > 0)
+ {
+ await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var stale = staleIds.ToHashSet();
+ var preferences = await dbContext.Preferences
+ .Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var changed = false;
+
+ foreach (var preference in preferences)
+ {
+ var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var rewritten = new List<string>(values.Length);
+ var seen = new HashSet<Guid>();
+ var touched = false;
+
+ foreach (var value in values)
+ {
+ // Clients write these in both the dashed and the plain form, so compare them parsed.
+ if (!Guid.TryParse(value, out var parsed))
+ {
+ rewritten.Add(value);
+ continue;
+ }
+
+ var isStale = stale.Contains(parsed);
+ if (isStale)
+ {
+ parsed = canonicalId;
+ touched = true;
+ }
+
+ // The same view can be listed twice once both of its ids point at the same place.
+ if (!seen.Add(parsed))
+ {
+ continue;
+ }
+
+ rewritten.Add(isStale
+ ? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCulture)
+ : value);
+ }
+
+ if (!touched)
+ {
+ continue;
+ }
+
+ preference.Value = string.Join(',', rewritten);
+ changed = true;
+ }
+
+ if (changed)
+ {
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private static async Task MoveAncestorsAsync(
+ JellyfinDbContext dbContext,
+ Guid canonicalId,
+ IReadOnlyList<Guid> staleIds,
+ CancellationToken cancellationToken)
+ {
+ var items = await dbContext.AncestorIds
+ .WhereOneOrMany(staleIds, e => e.ParentItemId)
+ .Select(e => e.ItemId)
+ .Distinct()
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.AncestorIds
+ .WhereOneOrMany(staleIds, e => e.ParentItemId)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (items.Count == 0)
+ {
+ return;
+ }
+
+ // The pair is the primary key, so anything already recorded against the canonical view stays put.
+ var existing = await dbContext.AncestorIds
+ .Where(e => e.ParentItemId.Equals(canonicalId))
+ .Select(e => e.ItemId)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ foreach (var itemId in items.Except(existing))
+ {
+ dbContext.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = canonicalId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
index c25694aba5..1e2d94d2a4 100644
--- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
+++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
@@ -173,10 +173,7 @@ namespace MediaBrowser.Controller.Entities.Audio
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validName = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.ArtistsPath, validName);
}
diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs
index 65669e6804..23b3341dbc 100644
--- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs
+++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs
@@ -80,10 +80,7 @@ namespace MediaBrowser.Controller.Entities.Audio
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validName = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.MusicGenrePath, validName);
}
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 28f40cb7fa..d030c8f420 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -48,6 +48,10 @@ namespace MediaBrowser.Controller.Entities
public const string ThemeSongFileName = "theme";
+ // Well below the 255 byte limit of the common Linux filesystems and the 255 character limit
+ // of Windows, so the files inside the folder still fit within MAX_PATH.
+ private const int MaxItemByNameFolderNameBytes = 128;
+
/// <summary>
/// The supported image extensions.
/// </summary>
@@ -942,6 +946,43 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
+ /// Turns an item-by-name entity's name into a folder name every supported filesystem accepts.
+ /// </summary>
+ /// <param name="name">The entity's name.</param>
+ /// <returns>The folder name.</returns>
+ public static string GetItemByNameFolderName(string name)
+ {
+ // Trim the period at the end because windows will have a hard time with that
+ var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.');
+
+ // Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be
+ // turned into a folder at all - and an entity with no folder can never be created, which
+ // leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan.
+ // Only broken provider data gets this long, but it still has to resolve to something, so
+ // keep a readable prefix and let a hash of the whole name tell two of them apart.
+ if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes)
+ {
+ return validName;
+ }
+
+ var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ var budget = MaxItemByNameFolderNameBytes - suffix.Length;
+ var length = Math.Min(validName.Length, budget);
+ while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget)
+ {
+ length--;
+ }
+
+ // Never cut a surrogate pair in half, the lone half is not a valid file name character.
+ if (length > 0 && char.IsHighSurrogate(validName[length - 1]))
+ {
+ length--;
+ }
+
+ return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix);
+ }
+
+ /// <summary>
/// Cleans a raw name into its sortable form by applying the configured sort rules.
/// </summary>
/// <param name="name">The raw name to clean.</param>
diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs
index 6ec78a270e..ef8acaef92 100644
--- a/MediaBrowser.Controller/Entities/Genre.cs
+++ b/MediaBrowser.Controller/Entities/Genre.cs
@@ -83,10 +83,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validName = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.GenrePath, validName);
}
diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs
index 14325d971a..bba5005eed 100644
--- a/MediaBrowser.Controller/Entities/Person.cs
+++ b/MediaBrowser.Controller/Entities/Person.cs
@@ -98,10 +98,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validFilename = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validFilename = normalizeName ? GetItemByNameFolderName(name) : name;
string subFolderPrefix = null;
diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs
index 9103b09a95..a944b356c8 100644
--- a/MediaBrowser.Controller/Entities/Studio.cs
+++ b/MediaBrowser.Controller/Entities/Studio.cs
@@ -78,10 +78,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validName = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.StudioPath, validName);
}
diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs
index 37820296cc..03fb2156d3 100644
--- a/MediaBrowser.Controller/Entities/Year.cs
+++ b/MediaBrowser.Controller/Entities/Year.cs
@@ -85,10 +85,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validName = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.YearPath, validName);
}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index ca686fbd9d..2a6ea214b8 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -606,6 +606,12 @@ namespace MediaBrowser.Controller.Library
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query);
/// <summary>
+ /// Deletes every credit that no item maps to any more.
+ /// </summary>
+ /// <returns>The number of credits that were deleted.</returns>
+ int DeleteOrphanedCredits();
+
+ /// <summary>
/// Gets the distinct people names per item for multiple items.
/// </summary>
/// <param name="itemIds">The item IDs.</param>
diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
index 9811241d31..15183a8806 100644
--- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
@@ -34,6 +34,12 @@ public interface IPeopleRepository
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter);
/// <summary>
+ /// Deletes every credit that no item maps to any more.
+ /// </summary>
+ /// <returns>The number of credits that were deleted.</returns>
+ int DeleteOrphanedCredits();
+
+ /// <summary>
/// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table.
/// </summary>
/// <param name="itemIds">The item IDs to get people for.</param>
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index f562d64ddd..d51d913caa 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -27,6 +27,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
/// <summary>Provider for OMDB service.</summary>
public class OmdbProvider
{
+ /// <summary>Generational suffixes that OMDb separates from the name with a comma.</summary>
+ private static readonly string[] NameSuffixes = ["Jr", "Jnr", "Sr", "Snr", "II", "III", "IV", "V"];
+
private readonly IFileSystem _fileSystem;
private readonly IServerConfigurationManager _configurationManager;
private readonly IHttpClientFactory _httpClientFactory;
@@ -420,42 +423,96 @@ namespace MediaBrowser.Providers.Plugins.Omdb
return;
}
- if (!string.IsNullOrWhiteSpace(result.Director))
- {
- var person = new PersonInfo
- {
- Name = result.Director.Trim(),
- Type = PersonKind.Director
- };
+ AddPeople(itemResult, result.Director, PersonKind.Director);
+ AddPeople(itemResult, result.Writer, PersonKind.Writer);
+ AddPeople(itemResult, result.Actors, PersonKind.Actor);
+ }
- itemResult.AddPerson(person);
+ /// <summary>Adds the people from a comma separated OMDb credit list.</summary>
+ /// <typeparam name="T">The item type.</typeparam>
+ /// <param name="itemResult">The metadata result to add the people to.</param>
+ /// <param name="credits">The comma separated OMDb credit list.</param>
+ /// <param name="type">The kind of person each credit describes.</param>
+ internal static void AddPeople<T>(MetadataResult<T> itemResult, string credits, PersonKind type)
+ where T : BaseItem
+ {
+ if (string.IsNullOrWhiteSpace(credits))
+ {
+ return;
}
- if (!string.IsNullOrWhiteSpace(result.Writer))
+ var names = new List<string>();
+
+ foreach (var credit in SplitCredits(credits))
{
- var person = new PersonInfo
+ // OMDb annotates the credited role in parentheses, e.g. "Mari Okada (screenplay)". The same
+ // person can be credited more than once this way, so strip it and let AddPerson deduplicate.
+ var name = credit;
+ var annotation = name.IndexOf('(', StringComparison.Ordinal);
+ if (annotation >= 0)
+ {
+ name = name[..annotation];
+ }
+
+ name = name.Trim();
+ if (name.Length == 0)
{
- Name = result.Writer.Trim(),
- Type = PersonKind.Writer
- };
+ continue;
+ }
+
+ // A generational suffix is separated from the name it belongs to by the same comma the list
+ // uses, e.g. "Jack Salvatore, Jr.", so it has to be joined back instead of becoming a credit.
+ if (names.Count > 0 && IsNameSuffix(name))
+ {
+ names[^1] = names[^1] + ", " + name;
+ continue;
+ }
- itemResult.AddPerson(person);
+ names.Add(name);
}
- if (!string.IsNullOrWhiteSpace(result.Actors))
+ foreach (var name in names)
{
- var actorList = result.Actors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- foreach (var actor in actorList)
+ itemResult.AddPerson(new PersonInfo
{
- var person = new PersonInfo
- {
- Name = actor,
- Type = PersonKind.Actor
- };
+ Name = name,
+ Type = type
+ });
+ }
+ }
+
+ // Only the commas between credits, never one inside an annotation: "Jerry Siegel (created by:
+ // Superman, Superboy)" is one credit, and splitting it blindly invents a person called "Superboy)".
+ private static IEnumerable<string> SplitCredits(string credits)
+ {
+ var depth = 0;
+ var start = 0;
- itemResult.AddPerson(person);
+ for (var i = 0; i < credits.Length; i++)
+ {
+ switch (credits[i])
+ {
+ case '(':
+ depth++;
+ break;
+ case ')':
+ depth = Math.Max(0, depth - 1);
+ break;
+ case ',' when depth == 0:
+ yield return credits[start..i];
+ start = i + 1;
+ break;
}
}
+
+ yield return credits[start..];
+ }
+
+ private static bool IsNameSuffix(string value)
+ {
+ var suffix = value.EndsWith('.') ? value[..^1] : value;
+
+ return NameSuffixes.Contains(suffix, StringComparer.OrdinalIgnoreCase);
}
private static bool IsConfiguredForEnglish(BaseItem item, string language)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index 9e201f2d7c..6163e20194 100755
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -363,39 +363,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
{
var config = Plugin.Instance.Configuration;
- if (seriesResult.Credits?.Cast is not null)
+ // The aggregated credits are what hold an actor's several characters apart; the flat ones
+ // put them in a single string. Only the aggregated list carries the whole run, so prefer it
+ // and fall back for the rare show TMDb has no aggregation for.
+ var cast = seriesResult.AggregateCredits?.Cast is { Count: > 0 } aggregated
+ ? TmdbUtils.MapAggregateCast(aggregated, config, _tmdbClientManager.GetProfileUrl)
+ : TmdbUtils.MapCast(seriesResult.Credits?.Cast, config, _tmdbClientManager.GetProfileUrl);
+
+ foreach (var actor in cast)
{
- IEnumerable<Cast> castQuery = seriesResult.Credits.Cast.OrderBy(a => a.Order);
-
- if (config.HideMissingCastMembers)
- {
- castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath));
- }
-
- foreach (var actor in castQuery.Take(config.MaxCastMembers))
- {
- if (string.IsNullOrWhiteSpace(actor.Name))
- {
- continue;
- }
-
- var personInfo = new PersonInfo
- {
- Name = actor.Name.Trim(),
- Role = actor.Character?.Trim() ?? string.Empty,
- Type = PersonKind.Actor,
- SortOrder = actor.Order,
- // NOTE: Null values are filtered out above
- ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath!)
- };
-
- if (actor.Id > 0)
- {
- personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture));
- }
-
- yield return personInfo;
- }
+ yield return actor;
}
if (seriesResult.Credits?.Crew is not null)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index c8e3a7aa52..5379796465 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -137,7 +137,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
await EnsureClientConfigAsync().ConfigureAwait(false);
- var extraMethods = TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups;
+ var extraMethods = TvShowMethods.Credits | TvShowMethods.CreditsAggregate | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups;
if (!(Plugin.Instance?.Configuration.ExcludeTagsSeries).GetValueOrDefault())
{
extraMethods |= TvShowMethods.Keywords;
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
index c83174f97f..44a2f7291e 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -3,10 +3,13 @@ using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
+using System.Linq;
using System.Text.RegularExpressions;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
using TMDbLib.Objects.General;
+using TMDbLib.Objects.TvShows;
+using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo;
namespace MediaBrowser.Providers.Plugins.Tmdb
{
@@ -130,6 +133,100 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
+ /// Maps an aggregated TMDb cast list, whose entries hold every role their member played.
+ /// </summary>
+ /// <param name="cast">The aggregated cast list, or <c>null</c>.</param>
+ /// <param name="config">The configuration deciding how much of the cast to keep.</param>
+ /// <param name="getProfileUrl">Resolves a profile path into an absolute image url.</param>
+ /// <returns>One credit per role played.</returns>
+ internal static IEnumerable<PersonInfo> MapAggregateCast(
+ IReadOnlyList<CastAggregate>? cast,
+ PluginConfiguration config,
+ Func<string?, string?> getProfileUrl)
+ {
+ if (cast is null)
+ {
+ yield break;
+ }
+
+ var billed = cast
+ .Where(member => !string.IsNullOrWhiteSpace(member.Name))
+ .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath))
+ .OrderBy(member => member.Order)
+ .Take(config.MaxCastMembers);
+
+ foreach (var member in billed)
+ {
+ // An actor playing several characters over the run gets one aggregated entry holding
+ // every role, so each of them becomes a credit of its own here. Their own billing puts
+ // the character they played the longest first.
+ var characters = member.Roles?
+ .Where(role => !string.IsNullOrWhiteSpace(role.Character))
+ .OrderByDescending(role => role.EpisodeCount)
+ .Select(role => role.Character!.Trim())
+ .ToArray();
+
+ if (characters is null || characters.Length == 0)
+ {
+ characters = [string.Empty];
+ }
+
+ foreach (var character in characters)
+ {
+ yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, character, getProfileUrl);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Maps a TMDb cast list whose entries hold the one character their member is credited for.
+ /// </summary>
+ /// <param name="cast">The cast list, or <c>null</c>.</param>
+ /// <param name="config">The configuration deciding how much of the cast to keep.</param>
+ /// <param name="getProfileUrl">Resolves a profile path into an absolute image url.</param>
+ /// <returns>One credit per cast entry.</returns>
+ internal static IEnumerable<PersonInfo> MapCast(
+ IReadOnlyList<Cast>? cast,
+ PluginConfiguration config,
+ Func<string?, string?> getProfileUrl)
+ {
+ if (cast is null)
+ {
+ yield break;
+ }
+
+ var billed = cast
+ .Where(member => !string.IsNullOrWhiteSpace(member.Name))
+ .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath))
+ .OrderBy(member => member.Order)
+ .Take(config.MaxCastMembers);
+
+ foreach (var member in billed)
+ {
+ yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, member.Character?.Trim() ?? string.Empty, getProfileUrl);
+ }
+ }
+
+ private static PersonInfo CreateCredit(string name, int id, string? profilePath, int? order, string role, Func<string?, string?> getProfileUrl)
+ {
+ var personInfo = new PersonInfo
+ {
+ Name = name.Trim(),
+ Role = role,
+ Type = PersonKind.Actor,
+ SortOrder = order,
+ ImageUrl = getProfileUrl(profilePath)
+ };
+
+ if (id > 0)
+ {
+ personInfo.SetProviderId(MetadataProvider.Tmdb, id.ToString(CultureInfo.InvariantCulture));
+ }
+
+ return personInfo;
+ }
+
+ /// <summary>
/// Determines whether a video is a trailer.
/// </summary>
/// <param name="video">The TMDb video.</param>
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index 173d3c3e8e..2edf7681db 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -1262,7 +1262,7 @@ namespace Jellyfin.LiveTv
public Folder GetInternalLiveTvFolder(CancellationToken cancellationToken)
{
- var name = _localization.GetLocalizedString("HeaderLiveTV");
+ var name = _localization.GetServerLocalizedString("HeaderLiveTV");
return _libraryManager.GetNamedView(name, CollectionType.livetv, name);
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index e34eb0bda3..86bac4256a 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
@@ -27,6 +28,57 @@ namespace Jellyfin.Controller.Tests.Entities;
public class BaseItemTests
{
+ [Fact]
+ public void GetItemByNameFolderName_ShortName_IsKeptAsIs()
+ {
+ SetupPassThroughFileSystem();
+
+ Assert.Equal("Mairghread Scott", BaseItem.GetItemByNameFolderName("Mairghread Scott."));
+ }
+
+ [Fact]
+ public void GetItemByNameFolderName_OverlongName_FitsInAPathComponent()
+ {
+ SetupPassThroughFileSystem();
+
+ // What a provider result that concatenated a whole credit list into one name looks like.
+ var name = string.Join(", ", Enumerable.Repeat("Jerry Siegel (created by: Superman)", 20));
+
+ var folderName = BaseItem.GetItemByNameFolderName(name);
+
+ Assert.True(Encoding.UTF8.GetByteCount(folderName) <= 128);
+ Assert.StartsWith("Jerry Siegel (created by: Superman)", folderName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetItemByNameFolderName_OverlongNamesSharingAPrefix_StayApart()
+ {
+ SetupPassThroughFileSystem();
+
+ var prefix = new string('a', 200);
+
+ Assert.NotEqual(
+ BaseItem.GetItemByNameFolderName(prefix + "Joe Shuster"),
+ BaseItem.GetItemByNameFolderName(prefix + "Bob Kane"));
+ }
+
+ [Fact]
+ public void GetItemByNameFolderName_OverlongName_IsStable()
+ {
+ SetupPassThroughFileSystem();
+
+ var name = new string('a', 300);
+
+ Assert.Equal(BaseItem.GetItemByNameFolderName(name), BaseItem.GetItemByNameFolderName(name));
+ }
+
+ private static void SetupPassThroughFileSystem()
+ {
+ var fileSystem = new Mock<IFileSystem>();
+ fileSystem.Setup(x => x.GetValidFilename(It.IsAny<string>())).Returns((string name) => name);
+ BaseItem.FileSystem = fileSystem.Object;
+ }
+
[Theory]
[InlineData("", "")]
[InlineData("1", "0000000001")]
diff --git a/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs
new file mode 100644
index 0000000000..bd50a903f1
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs
@@ -0,0 +1,81 @@
+using System.Linq;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Providers.Plugins.Omdb;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Omdb
+{
+ public class OmdbProviderTests
+ {
+ [Fact]
+ public void AddPeople_CommaSeparatedList_SplitsIntoIndividualPeople()
+ {
+ var result = new MetadataResult<Movie>();
+
+ OmdbProvider.AddPeople(result, "Philip G. Epstein, Julius J. Epstein, Howard Koch", PersonKind.Writer);
+
+ Assert.Equal(
+ new[] { "Philip G. Epstein", "Julius J. Epstein", "Howard Koch" },
+ result.People!.Select(p => p.Name));
+ Assert.All(result.People!, p => Assert.Equal(PersonKind.Writer, p.Type));
+ }
+
+ [Fact]
+ public void AddPeople_RoleAnnotations_AreStrippedAndDeduplicated()
+ {
+ var result = new MetadataResult<Movie>();
+
+ OmdbProvider.AddPeople(result, "Mari Okada (screenplay), Mari Okada (story), Jun'ichi Satô (screenplay), Jun'ichi Satô (story)", PersonKind.Writer);
+
+ Assert.Equal(
+ new[] { "Mari Okada", "Jun'ichi Satô" },
+ result.People!.Select(p => p.Name));
+ }
+
+ [Theory]
+ [InlineData(
+ "Jerry Siegel (created by: Superman, Superboy), Bob Kane (created by: Batman)",
+ "Jerry Siegel|Bob Kane")]
+ [InlineData("Alan Moore (created by: John Constantine)", "Alan Moore")]
+ public void AddPeople_CommaInsideAnAnnotation_StaysOneCredit(string credits, string expected)
+ {
+ var result = new MetadataResult<Movie>();
+
+ OmdbProvider.AddPeople(result, credits, PersonKind.Writer);
+
+ Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name));
+ }
+
+ [Theory]
+ [InlineData("Jack Salvatore, Jr.", "Jack Salvatore, Jr.")]
+ [InlineData("Efrem Zimbalist, Jr., Tom Hanks", "Efrem Zimbalist, Jr.|Tom Hanks")]
+ [InlineData("Tom Hanks, Sammy Davis, Jr", "Tom Hanks|Sammy Davis, Jr")]
+ [InlineData("Harold Ramis, Ken Griffey, III (voice)", "Harold Ramis|Ken Griffey, III")]
+ [InlineData("Robert Downey Jr., Gwyneth Paltrow", "Robert Downey Jr.|Gwyneth Paltrow")]
+ [InlineData("Jr., Tom Hanks", "Jr.|Tom Hanks")]
+ public void AddPeople_GenerationalSuffix_StaysWithItsName(string credits, string expected)
+ {
+ var result = new MetadataResult<Movie>();
+
+ OmdbProvider.AddPeople(result, credits, PersonKind.Actor);
+
+ Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("(uncredited)")]
+ public void AddPeople_NoUsableName_AddsNothing(string? credits)
+ {
+ var result = new MetadataResult<Movie>();
+
+ OmdbProvider.AddPeople(result, credits!, PersonKind.Actor);
+
+ Assert.Null(result.People);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs
new file mode 100644
index 0000000000..182e7c52eb
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs
@@ -0,0 +1,105 @@
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.Tmdb;
+using TMDbLib.Objects.TvShows;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Tmdb
+{
+ public class TmdbUtilsCastTests
+ {
+ private static readonly PluginConfiguration _config = new() { MaxCastMembers = 10 };
+
+ [Fact]
+ public void MapAggregateCast_MemberWithSeveralRoles_YieldsOneCreditPerRole()
+ {
+ var cast = new List<CastAggregate>
+ {
+ CreateAggregate("Megumi Toyoguchi", 1, 0, ("Tabby (voice)", 3), ("Mimiru (voice)", 12))
+ };
+
+ var people = TmdbUtils.MapAggregateCast(cast, _config, _ => null).ToArray();
+
+ // The character they played the longest comes first, which is their own billing.
+ Assert.Equal(["Mimiru (voice)", "Tabby (voice)"], people.Select(p => p.Role));
+ Assert.All(people, p => Assert.Equal("Megumi Toyoguchi", p.Name));
+ Assert.All(people, p => Assert.Equal(PersonKind.Actor, p.Type));
+ Assert.All(people, p => Assert.Equal("1", p.GetProviderId(MetadataProvider.Tmdb)));
+ }
+
+ [Fact]
+ public void MapAggregateCast_MemberWithoutARole_IsStillCredited()
+ {
+ var cast = new List<CastAggregate> { CreateAggregate("Uncredited Actor", 2, 0) };
+
+ var person = Assert.Single(TmdbUtils.MapAggregateCast(cast, _config, _ => null));
+
+ Assert.Equal(string.Empty, person.Role);
+ }
+
+ [Fact]
+ public void MapAggregateCast_MoreThanConfigured_KeepsTheTopBilled()
+ {
+ var cast = Enumerable.Range(0, 5)
+ .Select(i => CreateAggregate($"Actor {4 - i}", i + 1, 4 - i, ($"Role {4 - i}", 1)))
+ .ToList();
+
+ var people = TmdbUtils.MapAggregateCast(cast, new PluginConfiguration { MaxCastMembers = 2 }, _ => null);
+
+ Assert.Equal(["Actor 0", "Actor 1"], people.Select(p => p.Name));
+ }
+
+ [Fact]
+ public void MapAggregateCast_HideMissingCastMembers_DropsTheOnesWithoutAProfile()
+ {
+ var withProfile = CreateAggregate("Has Profile", 1, 0, ("Hero", 1));
+ withProfile.ProfilePath = "/profile.jpg";
+ var cast = new List<CastAggregate> { withProfile, CreateAggregate("No Profile", 2, 1, ("Villain", 1)) };
+
+ var people = TmdbUtils.MapAggregateCast(
+ cast,
+ new PluginConfiguration { MaxCastMembers = 10, HideMissingCastMembers = true },
+ _ => null);
+
+ Assert.Equal(["Has Profile"], people.Select(p => p.Name));
+ }
+
+ [Fact]
+ public void MapCast_FlatCredits_YieldOneCreditEach()
+ {
+ var cast = new List<Cast>
+ {
+ new() { Name = "Kevin Conroy", Id = 1, Order = 0, Character = " Batman (voice) " },
+ new() { Name = " ", Id = 2, Order = 1, Character = "Nobody" }
+ };
+
+ var person = Assert.Single(TmdbUtils.MapCast(cast, _config, _ => null));
+
+ Assert.Equal("Kevin Conroy", person.Name);
+ Assert.Equal("Batman (voice)", person.Role);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void MapCast_NoCast_YieldsNothing(bool aggregate)
+ {
+ Assert.Empty(aggregate
+ ? TmdbUtils.MapAggregateCast(null, _config, _ => null)
+ : TmdbUtils.MapCast(null, _config, _ => null));
+ }
+
+ private static CastAggregate CreateAggregate(string name, int id, int order, params (string Character, int Episodes)[] roles)
+ {
+ return new CastAggregate
+ {
+ Name = name,
+ Id = id,
+ Order = order,
+ Roles = roles.Select(role => new CastRole { Character = role.Character, EpisodeCount = role.Episodes }).ToList()
+ };
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs
new file mode 100644
index 0000000000..c2518f13b2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+using ItemSortBy = Jellyfin.Data.Enums.ItemSortBy;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers ordering by <see cref="ItemSortBy.IsPlayed"/> and <see cref="ItemSortBy.IsUnplayed"/>, which
+/// has to read the played state the isPlayed filter reports: folders hold none of their own and count
+/// as played once no descendant is left unplayed.
+/// </summary>
+public sealed class BaseItemRepositoryPlayedOrderingTests : SqliteDbTestFixture
+{
+ private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+ private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+
+ private readonly BaseItemRepository _repository;
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ // Names run A..F so name order interleaves the two groups: a dropped or inverted played key shows
+ // up as a different sequence rather than as the expected one by luck.
+ private readonly Guid _watchedSeries = Guid.NewGuid();
+ private readonly Guid _unwatchedSeries = Guid.NewGuid();
+ private readonly Guid _partiallyWatchedSeries = Guid.NewGuid();
+ private readonly Guid _secondUnwatchedSeries = Guid.NewGuid();
+ private readonly Guid _thirdUnwatchedSeries = Guid.NewGuid();
+ private readonly Guid _secondWatchedSeries = Guid.NewGuid();
+
+ // Box sets reach their children through LinkedChildren instead of the ancestor chain.
+ private readonly Guid _watchedBoxSet = Guid.NewGuid();
+ private readonly Guid _unwatchedBoxSet = Guid.NewGuid();
+
+ private readonly HashSet<Guid> _unwatchedSeriesIds;
+
+ public BaseItemRepositoryPlayedOrderingTests()
+ {
+ _unwatchedSeriesIds = [_unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries];
+
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void IsPlayed_OrdersUnwatchedSeriesBeforeWatchedOnes()
+ {
+ Assert.Equal(
+ [_unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries, _watchedSeries, _secondWatchedSeries],
+ SeriesIds(ItemSortBy.IsPlayed));
+ }
+
+ [Fact]
+ public void IsPlayed_CountsAPartiallyWatchedSeriesAsUnwatched()
+ {
+ var ids = SeriesIds(ItemSortBy.IsPlayed);
+
+ Assert.True(ids.IndexOf(_partiallyWatchedSeries) < ids.IndexOf(_watchedSeries));
+ }
+
+ [Fact]
+ public void IsUnplayed_ReversesTheGroups()
+ {
+ Assert.Equal(
+ [_watchedSeries, _secondWatchedSeries, _unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries],
+ SeriesIds(ItemSortBy.IsUnplayed));
+ }
+
+ [Fact]
+ public void IsPlayed_OrdersAnUnwatchedBoxSetBeforeAWatchedOne()
+ {
+ var ids = _repository
+ .GetItemList(Query(BaseItemKind.BoxSet, (ItemSortBy.IsPlayed, SortOrder.Ascending)))
+ .Select(i => i.Id);
+
+ Assert.Equal([_unwatchedBoxSet, _watchedBoxSet], ids);
+ }
+
+ [Fact]
+ public void IsPlayedThenRandom_StillPlacesEveryUnwatchedSeriesFirst()
+ {
+ var order = _repository
+ .GetItemList(Query(BaseItemKind.Series, (ItemSortBy.IsPlayed, SortOrder.Ascending), (ItemSortBy.Random, SortOrder.Ascending)))
+ .Select(i => i.Id);
+
+ Assert.Equal(_unwatchedSeriesIds, order.Take(_unwatchedSeriesIds.Count).ToHashSet());
+ }
+
+ [Fact]
+ public void IsPlayedThenRandom_FillsAPageWithUnwatchedSeries()
+ {
+ var page = _repository.GetItems(new InternalItemsQuery(_user)
+ {
+ IncludeItemTypes = [BaseItemKind.Series],
+ OrderBy = [(ItemSortBy.IsPlayed, SortOrder.Ascending), (ItemSortBy.Random, SortOrder.Ascending)],
+ Limit = 4,
+ EnableTotalRecordCount = true
+ });
+
+ Assert.Equal(6, page.TotalRecordCount);
+ Assert.Equal(_unwatchedSeriesIds, page.Items.Select(i => i.Id).ToHashSet());
+ }
+
+ private List<Guid> SeriesIds(ItemSortBy sortBy)
+ => _repository
+ .GetItemList(Query(BaseItemKind.Series, (sortBy, SortOrder.Ascending)))
+ .Select(i => i.Id)
+ .ToList();
+
+ private InternalItemsQuery Query(BaseItemKind kind, params (ItemSortBy OrderBy, SortOrder SortOrder)[] orderBy)
+ => new(_user)
+ {
+ IncludeItemTypes = [kind],
+ OrderBy = orderBy
+ };
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.Users.Add(_user);
+
+ AddSeries(context, _watchedSeries, "A watched", playedEpisodes: 1, unplayedEpisodes: 0);
+ AddSeries(context, _unwatchedSeries, "B unwatched", playedEpisodes: 0, unplayedEpisodes: 1);
+ AddSeries(context, _partiallyWatchedSeries, "C partially watched", playedEpisodes: 1, unplayedEpisodes: 1);
+ AddSeries(context, _secondUnwatchedSeries, "D unwatched", playedEpisodes: 0, unplayedEpisodes: 1);
+ AddSeries(context, _thirdUnwatchedSeries, "E unwatched", playedEpisodes: 0, unplayedEpisodes: 1);
+ AddSeries(context, _secondWatchedSeries, "F watched", playedEpisodes: 1, unplayedEpisodes: 0);
+
+ AddBoxSet(context, _watchedBoxSet, "A watched set", played: true);
+ AddBoxSet(context, _unwatchedBoxSet, "B unwatched set", played: false);
+
+ context.SaveChanges();
+ }
+
+ private void AddSeries(JellyfinDbContext context, Guid id, string name, int playedEpisodes, int unplayedEpisodes)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = id, Type = SeriesType, Name = name, SortName = name, PresentationUniqueKey = id.ToString("N"), IsFolder = true });
+
+ for (var i = 0; i < playedEpisodes + unplayedEpisodes; i++)
+ {
+ var episodeId = Guid.NewGuid();
+ context.BaseItems.Add(new BaseItemEntity { Id = episodeId, Type = EpisodeType, Name = $"{name} {i}", PresentationUniqueKey = episodeId.ToString("N"), SeriesId = id });
+ context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = id, Item = null!, ParentItem = null! });
+
+ if (i < playedEpisodes)
+ {
+ AddPlayedUserData(context, episodeId);
+ }
+ }
+ }
+
+ private void AddBoxSet(JellyfinDbContext context, Guid id, string name, bool played)
+ {
+ var movieId = Guid.NewGuid();
+
+ context.BaseItems.Add(new BaseItemEntity { Id = id, Type = BoxSetType, Name = name, SortName = name, PresentationUniqueKey = id.ToString("N"), IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = MovieType, Name = $"{name} movie", PresentationUniqueKey = movieId.ToString("N") });
+ context.LinkedChildren.Add(new LinkedChildEntity { ParentId = id, ChildId = movieId, ChildType = LinkedChildType.Manual, SortOrder = 0 });
+
+ if (played)
+ {
+ AddPlayedUserData(context, movieId);
+ }
+ }
+
+ private void AddPlayedUserData(JellyfinDbContext context, Guid itemId)
+ => context.UserData.Add(new UserData
+ {
+ ItemId = itemId,
+ UserId = _user.Id,
+ CustomDataKey = itemId.ToString("N"),
+ Played = true,
+ Item = null!,
+ User = null!
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
index 54565c5787..649458f733 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
@@ -142,6 +142,79 @@ public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture
Assert.Equal("Hero", map.Role);
}
+ [Fact]
+ public void UpdatePeople_CreditDroppedByTheProvider_LeavesNoCreditRowBehind()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("Person B", PersonKind.Actor, "Villain")
+ ]);
+
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
+
+ using var ctx = CreateDbContext();
+ Assert.Equal(["Person A"], ctx.Peoples.Select(e => e.Name).ToArray());
+ }
+
+ [Fact]
+ public void UpdatePeople_CreditStillHeldByAnotherItem_IsKept()
+ {
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
+ _repository.UpdatePeople(AddMovie("Other Movie"), [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
+
+ _repository.UpdatePeople(_itemId, []);
+
+ using var after = CreateDbContext();
+ Assert.Single(after.Peoples);
+ Assert.Single(after.PeopleBaseItemMap);
+ }
+
+ [Fact]
+ public void DeleteOrphanedCredits_CreditNoItemMapsTo_IsDeleted()
+ {
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
+ using (var ctx = CreateDbContext())
+ {
+ // The state a credit was left in before UpdatePeople cleaned up after itself.
+ ctx.PeopleBaseItemMap.RemoveRange(ctx.PeopleBaseItemMap);
+ ctx.SaveChanges();
+ }
+
+ Assert.Equal(1, _repository.DeleteOrphanedCredits());
+
+ using var after = CreateDbContext();
+ Assert.Empty(after.Peoples);
+ }
+
+ [Fact]
+ public void DeleteOrphanedCredits_CreditAnItemMapsTo_IsKept()
+ {
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
+
+ Assert.Equal(0, _repository.DeleteOrphanedCredits());
+
+ using var after = CreateDbContext();
+ Assert.Single(after.Peoples);
+ }
+
+ private Guid AddMovie(string name)
+ {
+ var id = Guid.NewGuid();
+ using var ctx = CreateDbContext();
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie],
+ Name = name,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ });
+ ctx.SaveChanges();
+ return id;
+ }
+
private static PersonInfo CreatePerson(string name, PersonKind type, string role)
{
return new PersonInfo
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
index 133a3f7d47..feb2d8a625 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
@@ -21,7 +21,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
{
var localizationMock = new Mock<ILocalizationManager>();
localizationMock
- .Setup(l => l.GetLocalizedString(It.IsAny<string>()))
+ .Setup(l => l.GetServerLocalizedString(It.IsAny<string>()))
.Returns("Season {0}");
_resolver = new SeasonResolver(