diff --git a/Directory.Packages.props b/Directory.Packages.props
index ac9830fc7e..4d5f0efa9e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -82,7 +82,7 @@
-
+
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/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 6fa057702c..2462a754ae 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -611,7 +611,11 @@ namespace Emby.Server.Implementations.Dto
// For these types we can try to optimize and assume these values will be equal
if (item is MusicAlbum || item is Season || item is Playlist)
{
- dto.ChildCount = dto.RecursiveItemCount;
+ if (dto.RecursiveItemCount > 0)
+ {
+ dto.ChildCount = dto.RecursiveItemCount;
+ }
+
var folderChildCount = folder.LinkedChildren.Length;
// The default is an empty array, so we can't reliably use the count when it's empty
if (folderChildCount > 0)
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 6a39b2177d..dd8c883684 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1914,14 +1914,14 @@ namespace Emby.Server.Implementations.Library
}
// Optimize by querying against top level views
- query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
- query.AncestorIds = [];
-
- // Prevent searching in all libraries due to empty filter
- if (query.TopParentIds.Length == 0)
+ var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
+ if (topParentIds.Length == 0)
{
- query.TopParentIds = [Guid.NewGuid()];
+ return;
}
+
+ query.TopParentIds = topParentIds;
+ query.AncestorIds = [];
}
public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query)
@@ -1967,12 +1967,15 @@ namespace Emby.Server.Implementations.Library
if (parents.All(i => i is ICollectionFolder || i is UserView))
{
// Optimize by querying against top level views
- query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
+ var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
- // Prevent searching in all libraries due to empty filter
- if (query.TopParentIds.Length == 0)
+ if (topParentIds.Length > 0)
{
- query.TopParentIds = [Guid.NewGuid()];
+ query.TopParentIds = topParentIds;
+ }
+ else
+ {
+ SetAncestorIds(query, parents);
}
}
else if (parents.Count == 1 && parents.First() is Folder folder
@@ -1996,19 +1999,24 @@ namespace Emby.Server.Implementations.Library
}
else
{
- // We need to be able to query from any arbitrary ancestor up the tree
- query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray();
-
- // Prevent searching in all libraries due to empty filter
- if (query.AncestorIds.Length == 0)
- {
- query.AncestorIds = [Guid.NewGuid()];
- }
+ SetAncestorIds(query, parents);
}
query.Parent = null;
}
+ private static void SetAncestorIds(InternalItemsQuery query, IReadOnlyCollection parents)
+ {
+ // We need to be able to query from any arbitrary ancestor up the tree
+ query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray();
+
+ // Prevent searching in all libraries due to empty filter
+ if (query.AncestorIds.Length == 0)
+ {
+ query.AncestorIds = [Guid.NewGuid()];
+ }
+ }
+
private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true)
{
if (query.User is null)
@@ -2519,9 +2527,15 @@ namespace Emby.Server.Implementations.Library
}
}
- if (!File.Exists(image.Path))
+ if (string.IsNullOrEmpty(image.Path) || !File.Exists(image.Path))
{
- _logger.LogWarning("Image not found at {ImagePath}", 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;
}
@@ -2919,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;
@@ -2943,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)
{
@@ -2963,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));
@@ -2993,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;
@@ -3094,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;
@@ -3128,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();
}
@@ -3550,6 +3580,12 @@ namespace Emby.Server.Implementations.Library
return _peopleRepository.GetPeopleNames(query);
}
+ ///
+ public int DeleteOrphanedCredits()
+ {
+ return _peopleRepository.DeleteOrphanedCredits();
+ }
+
///
public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes)
{
@@ -3595,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)
{
@@ -3617,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 9512b0ffd7..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);
}
@@ -396,6 +396,12 @@ namespace Emby.Server.Implementations.Library
query.Limit = limit;
return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies);
}
+
+ if (collectionType is null)
+ {
+ query.Limit = limit;
+ return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown);
+ }
}
return _libraryManager.GetItemList(query, parents);
diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
index dacef102dd..078a0b921d 100644
--- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
@@ -49,6 +49,14 @@ public class PeopleValidator
/// Task.
public async Task ValidatePeople(CancellationToken cancellationToken, IProgress progress)
{
+ // Before the refresh below walks them: a credit no item maps to any more stands for nothing,
+ // and while it is there the person it names cannot reach the dead-person sweep either.
+ var numOrphaned = _libraryManager.DeleteOrphanedCredits();
+ if (numOrphaned > 0)
+ {
+ _logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
+ }
+
var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
var numComplete = 0;
@@ -115,6 +123,6 @@ public class PeopleValidator
progress.Report(100);
- _logger.LogInformation("People validation complete");
+ _logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned);
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json
index 5d0ef65842..49ebc45f06 100644
--- a/Emby.Server.Implementations/Localization/Core/be.json
+++ b/Emby.Server.Implementations/Localization/Core/be.json
@@ -106,5 +106,11 @@
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
"CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
- "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
+ "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.",
+ "LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}",
+ "NameExtraDeletedScene": "Выдаленая сцэна",
+ "NameExtraInterview": "Інтэрв'ю",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraScene": "Сцэна",
+ "NameExtraTrailer": "Трэйлер"
}
diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json
index 0710a39708..3d49675c63 100644
--- a/Emby.Server.Implementations/Localization/Core/bg-BG.json
+++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json
@@ -106,5 +106,20 @@
"TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.",
"TaskExtractMediaSegments": "Сканиране за сегменти",
"CleanupUserDataTask": "Задача за почистване на потребителски данни",
- "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни."
+ "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.",
+ "LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}",
+ "NameExtraBehindTheScenes": "Зад кулисите",
+ "NameExtraScene": "Сцена",
+ "NameExtraShort": "Откъс",
+ "NameExtraThemeVideo": "Тематично видео",
+ "NameExtraTrailer": "Трейлър",
+ "NameExtraUnknown": "Екстра",
+ "NameExtraClip": "Клип",
+ "NameExtraDeletedScene": "Изтрита Сцена",
+ "NameExtraFeaturette": "Кратък филм",
+ "NameExtraInterview": "Интервю",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Пример",
+ "NameExtraThemeSong": "Тема-песен",
+ "Original": "Оригинал"
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index 298d60d277..a053fc2da9 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -24,8 +24,8 @@
"Music": "Music",
"MusicVideos": "Music Videos",
"NameInstallFailed": "{0} installation failed",
- "NameSeasonNumber": "Season {0}",
- "NameSeasonUnknown": "Season Unknown",
+ "NameSeasonNumber": "Series {0}",
+ "NameSeasonUnknown": "Series Unknown",
"NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.",
"NotificationOptionApplicationUpdateAvailable": "Application update available",
"NotificationOptionApplicationUpdateInstalled": "Application update installed",
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "User data cleanup task",
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.",
"LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
- "Original": "Original"
+ "Original": "Original",
+ "NameExtraBehindTheScenes": "Behind The Scenes",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Deleted Scene",
+ "NameExtraFeaturette": "Featurette",
+ "NameExtraInterview": "Interview",
+ "NameExtraSample": "Sample",
+ "NameExtraScene": "Scene",
+ "NameExtraShort": "Short",
+ "NameExtraThemeSong": "Theme Song",
+ "NameExtraThemeVideo": "Theme Video",
+ "NameExtraTrailer": "Trailer",
+ "NameExtraUnknown": "Extra",
+ "NameExtraNumbered": "{0} {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index d1e9065d97..6aa72908cb 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -13,7 +13,7 @@
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
"LabelIpAddressValue": "IP-atsetur: {0}",
- "AuthenticationSucceededWithUserName": "{0} varð samgildur",
+ "AuthenticationSucceededWithUserName": "{0} var samgildur",
"HeaderFavoriteShows": "Yndisrøðir",
"HeaderLiveTV": "Beinleiðis sjónvarp",
"HearingImpaired": "Hoyrnarveik",
@@ -68,7 +68,7 @@
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
"TasksApplicationCategory": "Nýtsluskipan",
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
- "NotificationOptionApplicationUpdateInstalled": "Skipanardagføring varð innløgd",
+ "NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd",
"UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
"HomeVideos": "Heimaupptøkur",
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
@@ -111,5 +111,15 @@
"NameExtraNumbered": "{0} {1}",
"NameExtraFeaturette": "Stuttur heimildarfilmur",
"TaskAudioNormalization": "Ljóðjavnan",
- "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan."
+ "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.",
+ "NameExtraSample": "Kut",
+ "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir",
+ "TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.",
+ "TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað",
+ "TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.",
+ "NameExtraThemeVideo": "Eyðkenniskykmynd",
+ "NameExtraDeletedScene": "Úrtikin mynd (scena)",
+ "NameExtraScene": "Mynd (scena)",
+ "NameExtraUnknown": "Eykatilfar",
+ "Original": "Upprunalig(t/ur)"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json
index 5fbf61c627..f4b1f86d1d 100644
--- a/Emby.Server.Implementations/Localization/Core/hi.json
+++ b/Emby.Server.Implementations/Localization/Core/hi.json
@@ -106,5 +106,20 @@
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
- "Original": "असली"
+ "Original": "असली",
+ "LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा",
+ "NameExtraBehindTheScenes": "परदे के पीछे",
+ "NameExtraClip": "क्लिप",
+ "NameExtraDeletedScene": "हटाया गया दृश्य",
+ "NameExtraFeaturette": "फीचरेट",
+ "NameExtraInterview": "साक्षात्कार",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "नमूना",
+ "NameExtraScene": "दृश्य",
+ "NameExtraShort": "शॉर्ट",
+ "NameExtraThemeSong": "थीम सॉन्ग",
+ "NameExtraThemeVideo": "थीम वीडियो",
+ "NameExtraTrailer": "ट्रेलर",
+ "NameExtraUnknown": "अतिरिक्त",
+ "CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index b0fb6c52ba..dbfeabd88e 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -87,7 +87,7 @@
"TaskCleanActivityLog": "Išvalyti veiklos žurnalą",
"Undefined": "Neapibrėžtas",
"Forced": "Priverstinis",
- "Default": "Numatytas",
+ "Default": "Numatytasis",
"TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.",
"TaskOptimizeDatabase": "Optimizuoti duomenų bazę",
"TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.",
diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json
index 031c6e17c4..997d534fea 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-BR.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json
@@ -120,5 +120,6 @@
"NameExtraThemeVideo": "Vídeo de Abertura",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Extra",
- "NameExtraFeaturette": "Nos Bastidores"
+ "NameExtraFeaturette": "Nos Bastidores",
+ "NameExtraInterview": "Entrevista"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index ea83b88951..358c19881f 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -108,5 +108,8 @@
"CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului",
"CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.",
"LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}",
- "Original": "Original"
+ "Original": "Original",
+ "NameExtraBehindTheScenes": "În culise",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Scenă ștearsă"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
index 30c85baaba..7d741bca36 100644
--- a/Emby.Server.Implementations/Localization/Core/sv.json
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -116,5 +116,10 @@
"NameExtraScene": "Scen",
"NameExtraShort": "Kortfilm",
"NameExtraThemeSong": "Signaturmelodi",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraClip": "Klipp",
+ "NameExtraFeaturette": "Kortfilm",
+ "NameExtraSample": "Prov",
+ "NameExtraThemeVideo": "Signaturvideo",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
index e4939205c9..29b633530f 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
@@ -174,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask
if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
{
t.LUFS = await CalculateLUFSAsync(
- string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
+ string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()),
false,
cancellationToken).ConfigureAwait(false);
toSaveDbItems.Add(t);
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
index dff9a473af..afb27ddf9e 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
@@ -177,33 +177,14 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
+ List peopleIds;
+
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
- const int PartitionSize = 100;
-
- var numPeople = await context.BaseItems
- .AsNoTracking()
- .Where(b => b.Type == personTypeName)
- .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
- .Where(b =>
- !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
- string.IsNullOrEmpty(b.Overview))
- .CountAsync(cancellationToken)
- .ConfigureAwait(false);
-
- _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
-
- if (numPeople == 0)
- {
- progress.Report(100);
- return;
- }
-
- var numComplete = 0;
- var numRefreshed = 0;
-
- await foreach (var entry in context.BaseItems
+ // Read the candidates in one go rather than paging them. A refresh stamps the person and takes
+ // it out of this set, so a growing offset over a shrinking set walks past people it never visits.
+ peopleIds = await context.BaseItems
.AsNoTracking()
.Where(b => b.Type == personTypeName)
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
@@ -211,22 +192,36 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
string.IsNullOrEmpty(b.Overview))
.OrderBy(b => b.Id)
- .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition))
- .PartitionEagerAsync(PartitionSize, cancellationToken)
- .WithCancellation(cancellationToken)
- .ConfigureAwait(false))
- {
- if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
- {
- numRefreshed++;
- }
+ .Select(b => b.Id)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
- numComplete++;
- progress.Report(100.0 * numComplete / numPeople);
+ _logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count);
+
+ if (peopleIds.Count == 0)
+ {
+ progress.Report(100);
+ return;
+ }
+
+ var numComplete = 0;
+ var numRefreshed = 0;
+
+ foreach (var personId in peopleIds)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false))
+ {
+ numRefreshed++;
}
- _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
+ numComplete++;
+ progress.Report(100.0 * numComplete / peopleIds.Count);
}
+
+ _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
}
private async Task RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
@@ -243,8 +238,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
- ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
- MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
+ ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh,
+ MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh
};
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index a6555a2beb..034a9dea55 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -20,7 +20,6 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Streaming;
-using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Entities;
@@ -1652,9 +1651,9 @@ public class DynamicHlsController : BaseJellyfinApiController
segmentFormat,
startNumber.ToString(CultureInfo.InvariantCulture),
baseUrlParam,
- EncodingUtils.NormalizePath(outputTsArg),
+ outputTsArg.EscapeProcessArgument(),
hlsArguments,
- EncodingUtils.NormalizePath(outputPath)).Trim();
+ outputPath.EscapeProcessArgument()).Trim();
}
///
diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs
index d009f80a96..39ba5ab186 100644
--- a/Jellyfin.Api/Controllers/ItemLookupController.cs
+++ b/Jellyfin.Api/Controllers/ItemLookupController.cs
@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Authorization;
@@ -263,7 +264,7 @@ public class ItemLookupController : BaseJellyfinApiController
searchResult.ProviderIds);
// Since the refresh process won't erase provider Ids, we need to set this explicitly now.
- item.ProviderIds = searchResult.ProviderIds;
+ item.SetProviderIds(searchResult.ProviderIds);
await _providerManager.RefreshFullItem(
item,
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 36c82cf461..65fffc4181 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -428,15 +428,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (request.ProviderIds is not null)
{
- foreach (var pair in request.ProviderIds.ToList())
- {
- if (string.IsNullOrEmpty(pair.Value))
- {
- request.ProviderIds.Remove(pair.Key);
- }
- }
-
- item.ProviderIds = request.ProviderIds;
+ item.SetProviderIds(request.ProviderIds);
}
if (item is Video video)
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index ea134a4619..da03032249 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -34,6 +34,8 @@ namespace Jellyfin.Api.Controllers;
[Tags("Library")]
public class UserLibraryController : BaseJellyfinApiController
{
+ private static readonly TimeSpan RefreshOnDemandTimeout = TimeSpan.FromSeconds(3);
+
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataRepository;
private readonly ILibraryManager _libraryManager;
@@ -79,7 +81,7 @@ public class UserLibraryController : BaseJellyfinApiController
/// An containing the item.
[HttpGet("Items/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult GetItem(
+ public async Task> GetItem(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
@@ -98,7 +100,7 @@ public class UserLibraryController : BaseJellyfinApiController
return NotFound();
}
- QueueRefreshOnDemandIfNeeded(item);
+ await RefreshOnDemandIfNeeded(item).ConfigureAwait(false);
var dtoOptions = new DtoOptions();
@@ -116,7 +118,7 @@ public class UserLibraryController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
[Obsolete("Kept for backwards compatibility")]
[ApiExplorerSettings(IgnoreApi = true)]
- public ActionResult GetItemLegacy(
+ public Task> GetItemLegacy(
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId)
=> GetItem(userId, itemId);
@@ -643,7 +645,7 @@ public class UserLibraryController : BaseJellyfinApiController
limit,
groupItems);
- private void QueueRefreshOnDemandIfNeeded(BaseItem item)
+ private async Task RefreshOnDemandIfNeeded(BaseItem item)
{
if (item is not Person)
{
@@ -656,15 +658,24 @@ public class UserLibraryController : BaseJellyfinApiController
return;
}
- _providerManager.QueueRefresh(
- item.Id,
- new MetadataRefreshOptions(new DirectoryService(_fileSystem))
- {
- MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
- ImageRefreshMode = MetadataRefreshMode.FullRefresh,
- ForceSave = true
- },
- RefreshPriority.High);
+ var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+ ForceSave = true
+ };
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted);
+ timeout.CancelAfter(RefreshOnDemandTimeout);
+
+ try
+ {
+ await item.RefreshMetadata(options, timeout.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested)
+ {
+ _providerManager.QueueRefresh(item.Id, options, RefreshPriority.High);
+ }
}
///
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> 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> AsOrderKey(Expression> predicate)
+ => Expression.Lambda>(Expression.Convert(predicate.Body, typeof(object)), predicate.Parameters);
+
///
public Expression> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable descendants)
{
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index c7acf72043..c9e08b1b5d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -110,7 +110,7 @@ public sealed partial class BaseItemRepository
PrepareFilterQuery(filter);
// Early exit if collection type is not supported
- if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
+ if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown)
{
return [];
}
@@ -121,30 +121,27 @@ public sealed partial class BaseItemRepository
var baseQuery = PrepareItemQuery(context, filter);
baseQuery = TranslateQuery(baseQuery, context, filter);
- if (collectionType == CollectionType.tvshows)
+ if (collectionType is CollectionType.tvshows)
{
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
if (collectionType is CollectionType.movies)
{
- // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
- // Build up until limit by streaming through results and deduplicating on the fly.
- var orderedIds = baseQuery
- .Where(e => e.PresentationUniqueKey != null)
- .OrderByDescending(e => e.DateCreated)
- .ThenByDescending(e => e.Id)
- .Select(e => new { e.Id, e.PresentationUniqueKey });
+ return GetLatestMovieItems(context, baseQuery, filter, limit);
+ }
- // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
- var firstIds = orderedIds
- .AsEnumerable()
- .DistinctBy(row => row.PresentationUniqueKey)
- .Select(row => row.Id)
+ if (collectionType is CollectionType.unknown)
+ {
+ var moviesQuery = baseQuery.Where(e => e.SeriesName == null);
+ var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit);
+ var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit);
+
+ return latestMovies.Concat(latestShows)
+ .OrderByDescending(dto => dto.DateCreated)
+ .ThenByDescending(dto => dto.Id)
.Take(limit ?? int.MaxValue)
.ToList();
-
- return LoadLatestByIds(context, firstIds, filter);
}
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
@@ -225,6 +222,39 @@ public sealed partial class BaseItemRepository
.ToArray()!;
}
+ ///
+ /// Gets the latest movies, deduplicated so each movie only appears once.
+ ///
+ /// The database context.
+ /// The query to pull movies from, with filters already applied.
+ /// The original query filter, used when loading the final items.
+ /// How many items to return.
+ /// The latest movies, newest first.
+ private IReadOnlyList GetLatestMovieItems(
+ JellyfinDbContext context,
+ IQueryable baseQuery,
+ InternalItemsQuery filter,
+ int? limit)
+ {
+ // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
+ // Build up until limit by streaming through results and deduplicating on the fly.
+ var orderedIds = baseQuery
+ .Where(e => e.PresentationUniqueKey != null)
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey });
+
+ // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
+ var firstIds = orderedIds
+ .AsEnumerable()
+ .DistinctBy(row => row.PresentationUniqueKey)
+ .Select(row => row.Id)
+ .Take(limit ?? int.MaxValue)
+ .ToList();
+
+ return LoadLatestByIds(context, firstIds, filter);
+ }
+
///
/// Gets the latest TV show items with smart Season/Series container selection.
///
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 4be9b04baa..1e30f0164e 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -35,6 +35,39 @@ public sealed partial class BaseItemRepository
// instance across several lambdas, and this filter is combined into a tree more than once.
private static Expression> IsFolderFilter => e => e.IsFolder;
+ // Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree.
+ private Expression> 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;
+
+ // The primary versions whose alternate version satisfies a dimension bound. Anchored on
+ // PrimaryVersionId so the filtered index carries it rather than a scan of every item.
+ private static IQueryable VersionsMatchingDimension(JellyfinDbContext context, Expression> bound)
+ => context.BaseItems
+ .Where(v => v.PrimaryVersionId != null)
+ .Where(bound)
+ .Select(v => v.PrimaryVersionId!.Value);
+
///
public IQueryable TranslateQuery(
IQueryable baseQuery,
@@ -70,47 +103,86 @@ public sealed partial class BaseItemRepository
include4K = true;
}
- // Non-folders: check own resolution directly (no subquery).
- // Folders (Series, BoxSets): EXISTS check on descendants/linked children.
- // Using navigation properties (a.Item, lc.Child) produces efficient
- // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
+ // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters
+ // on is the best any of the item's versions offers, not just the primary file's. Three sets,
+ // because a bucket is as much about what the version group does not have as what it does, and
+ // because an unprobed primary can still be placed by a version that does carry dimensions.
+ // The filtered PrimaryVersionId index keeps all three to the few items that have versions.
+ var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth);
+ var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth);
+ var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight);
+
+ // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row
+ // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself
+ // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather
+ // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed
+ // exactly as a top-level item is so that the two cannot disagree; the navigation properties
+ // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...).
baseQuery = baseQuery.Where(e =>
- (!e.IsFolder && e.Width > 0
- && ((includeSD && e.Width < HDWidth)
- || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
- || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
+ (!e.IsFolder
+ && ((includeSD
+ && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id))
+ && !versionsHd.Contains(e.Id)
+ && !versions4K.Contains(e.Id))
+ || (includeHD
+ && (e.Width >= HDWidth || versionsHd.Contains(e.Id))
+ && !(e.Width >= UHDWidth || e.Height >= UHDHeight)
+ && !versions4K.Contains(e.Id))
+ || (include4K
+ && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id)))))
|| (e.IsFolder
&& (e.Children!.Any(a =>
- a.Item.Width > 0
- && ((includeSD && a.Item.Width < HDWidth)
- || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))
- || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))))
+ (includeSD
+ && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId))
+ && !versionsHd.Contains(a.ItemId)
+ && !versions4K.Contains(a.ItemId))
+ || (includeHD
+ && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId))
+ && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)
+ && !versions4K.Contains(a.ItemId))
+ || (include4K
+ && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId))))
|| context.LinkedChildren.Any(lc =>
lc.ParentId == e.Id
- && lc.Child!.Width > 0
- && ((includeSD && lc.Child.Width < HDWidth)
- || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))
- || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
+ && ((includeSD
+ && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId))
+ && !versionsHd.Contains(lc.ChildId)
+ && !versions4K.Contains(lc.ChildId))
+ || (includeHD
+ && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId))
+ && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight)
+ && !versions4K.Contains(lc.ChildId))
+ || (include4K
+ && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId))))))));
}
+ // Same reasoning as the resolution filter: a dimension bound is met if any version meets it.
if (minWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width >= minWidth);
+ var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth);
+ baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id));
}
if (filter.MinHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight);
+ var minHeight = filter.MinHeight;
+ var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight);
+ baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id));
}
+ // An upper bound inverts that: it is met only if no version breaches it, since the item's
+ // resolution is the best its version group offers.
if (maxWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
+ var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth);
+ baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id));
}
if (filter.MaxHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight);
+ var maxHeight = filter.MaxHeight;
+ var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight);
+ baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id));
}
if (filter.IsLocked.HasValue)
@@ -472,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());
}
@@ -761,104 +818,144 @@ public sealed partial class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
- var lang = filter.HasNoAudioTrackWithLanguage;
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang));
+ var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang);
+ // A track only an alternate version carries still belongs to the item a caller sees, so the
+ // item's own streams alone do not decide this. Same for every stream filter below.
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithAudio.Contains(e.Id))
|| (e.IsFolder && !foldersWithAudio.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false));
+ var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true));
+ var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang));
+ var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (filter.HasSubtitles.HasValue)
{
var hasSubtitles = filter.HasSubtitles.Value;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles());
+ var criteria = new HasSubtitles();
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasSubtitles)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
}
if (filter.SubtitleLanguages.Count > 0)
{
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
- && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
+ && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
if (filter.AudioLanguages.Count > 0)
{
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages);
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
- && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
+ && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithAudio.Contains(e.Id)))
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
}
if (filter.HasChapterImages.HasValue)
{
var hasChapterImages = filter.HasChapterImages.Value;
- var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages());
+ var criteria = new HasChapterImages();
+ var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasChapterImages)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null)
+ || versionsWithChapterImages.Contains(e.Id)))
|| (e.IsFolder && foldersWithChapterImages.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)
+ && !versionsWithChapterImages.Contains(e.Id))
|| (e.IsFolder && !foldersWithChapterImages.Contains(e.Id)));
}
}
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index a320ba89d1..c42b5f9581 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService
///
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
+ ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
}
///
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
+ ArgumentNullException.ThrowIfNull(filter);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count();
}
@@ -283,10 +285,23 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
+ private IQueryable BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
+ {
+ var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();
+
+ var baseQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(descendantIds, b => b.Id)
+ .Where(DescendantQueryHelper.IsCountableLeaf);
+
+ return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
+ }
+
///
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
@@ -294,9 +309,9 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
+ var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
var baseQuery = dbContext.BaseItems
- .Where(b => allDescendantIds.Contains(b.Id))
+ .WhereOneOrMany(allDescendantIds, b => b.Id)
.Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
@@ -330,9 +345,17 @@ public class ItemCountService : IItemCountService
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
+
var result = new Dictionary();
foreach (var parentId in parentIds)
{
+ if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
+ {
+ result[parentId] = mergedCount;
+ continue;
+ }
+
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
@@ -342,6 +365,50 @@ public class ItemCountService : IItemCountService
return result;
}
+ private static Dictionary GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList parentIds)
+ {
+ var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
+ .Where(group => group.Value.Count > 1)
+ .ToArray();
+
+ if (mergedGroups.Length == 0)
+ {
+ return [];
+ }
+
+ // Only merged folders.
+ var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
+ var children = dbContext.BaseItems
+ .AsNoTracking()
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(memberIds, b => b.ParentId!.Value)
+ .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
+ .ToArray()
+ .GroupBy(b => b.ParentId)
+ .ToDictionary(
+ g => g.Key,
+ g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
+ ? b.Id.ToString("N", CultureInfo.InvariantCulture)
+ : b.PresentationUniqueKey).ToArray());
+
+ var result = new Dictionary();
+ foreach (var (parentId, members) in mergedGroups)
+ {
+ var childKeys = new HashSet(StringComparer.Ordinal);
+ foreach (var member in members)
+ {
+ if (children.TryGetValue(member, out var keys))
+ {
+ childKeys.UnionWith(keys);
+ }
+ }
+
+ result[parentId] = childKeys.Count;
+ }
+
+ return result;
+ }
+
///
public Dictionary GetPlayedAndTotalCountBatch(IReadOnlyList folderIds, User user)
{
@@ -354,10 +421,13 @@ public class ItemCountService : IItemCountService
}
using var dbContext = _dbProvider.CreateDbContext();
- var folderIdsArray = folderIds.ToArray();
var filter = new InternalItemsQuery(user);
var userId = user.Id;
+ // Merged series and seasons are stored as one row per folder-item sharing a presentation key.
+ var groups = GetPresentationKeyGroups(dbContext, folderIds);
+ var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();
+
var leafItems = dbContext.BaseItems
.Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
@@ -399,7 +469,7 @@ public class ItemCountService : IItemCountService
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
- var results = ancestorLeaves
+ var countsByFolder = ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
@@ -411,9 +481,73 @@ public class ItemCountService : IItemCountService
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
+ var results = new Dictionary();
+ foreach (var (folderId, members) in groups)
+ {
+ var played = 0;
+ var total = 0;
+
+ // Members of a group are distinct folders, so their leaves cannot overlap.
+ foreach (var member in members)
+ {
+ if (countsByFolder.TryGetValue(member, out var counts))
+ {
+ played += counts.Played;
+ total += counts.Total;
+ }
+ }
+
+ if (total > 0 || played > 0)
+ {
+ results[folderId] = (played, total);
+ }
+ }
+
return results;
}
+ private static Dictionary> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList folderIds)
+ {
+ var requested = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(folderIds, e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey })
+ .ToArray();
+
+ var keys = requested
+ .Select(e => e.PresentationUniqueKey)
+ .Where(key => !string.IsNullOrEmpty(key))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ // Every item that is not merged carries a key derived from its own id, so in the common case
+ // each group resolves back to the single folder that was asked for.
+ var membersByKey = keys.Length == 0
+ ? []
+ : dbContext.BaseItems
+ .AsNoTracking()
+ .Where(e => e.IsFolder)
+ .WhereOneOrMany(keys, e => e.PresentationUniqueKey!)
+ .Select(e => new { e.Id, Key = e.PresentationUniqueKey! })
+ .ToArray()
+ .GroupBy(e => e.Key, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);
+
+ var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
+ var groups = new Dictionary>();
+ foreach (var folderId in folderIds)
+ {
+ groups[folderId] = keyById.TryGetValue(folderId, out var key)
+ && !string.IsNullOrEmpty(key)
+ && membersByKey.TryGetValue(key, out var members)
+ && members.Count > 0
+ ? members
+ : [folderId];
+ }
+
+ return groups;
+ }
+
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable query, Guid userId)
{
var result = query
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index a592d0e6e2..da2ad033ec 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -194,12 +194,44 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
listOrder++;
}
+ var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray();
context.PeopleBaseItemMap.RemoveRange(existingMaps);
+ context.SaveChanges();
+
+ // Nothing else ever deletes a credit row, so one left without a single mapping outlives the
+ // credit it stood for: it keeps a person of that name off the dead-person sweep, which only
+ // sees items no credit names, and keeps the name in every by-name list. That is how a credit
+ // a provider dropped, or one a broken provider result invented, becomes impossible to clean up.
+ DeleteCreditsWithoutMapping(context, droppedCredits);
+
context.SaveChanges();
transaction.Commit();
}
+ ///
+ public int DeleteOrphanedCredits()
+ {
+ using var context = _dbProvider.CreateDbContext();
+
+ return DeleteCreditsWithoutMapping(context, null);
+ }
+
+ // A null candidate list sweeps every credit, anything else only the ones just unmapped.
+ private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList? candidates)
+ {
+ if (candidates is not null && candidates.Count == 0)
+ {
+ return 0;
+ }
+
+ var credits = candidates is null
+ ? context.Peoples.AsQueryable()
+ : context.Peoples.WhereOneOrMany(candidates, e => e.Id);
+
+ return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete();
+ }
+
///
public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes)
{
@@ -351,7 +383,11 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
if (!filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
+ var itemId = filter.ItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(itemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
if (filter.ParentId != null)
@@ -361,7 +397,11 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
if (!filter.AppearsInItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
+ var appearsInItemId = filter.AppearsInItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(appearsInItemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 932ced547a..fea6084267 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -225,19 +225,8 @@ namespace Jellyfin.Server.Implementations.Users
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
- dbContext.Permissions.RemoveRange(dbUser.Permissions);
- dbUser.Permissions.Clear();
- foreach (var permission in user.Permissions)
- {
- dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
- }
-
- dbContext.Preferences.RemoveRange(dbUser.Preferences);
- dbUser.Preferences.Clear();
- foreach (var preference in user.Preferences)
- {
- dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
- }
+ SyncPermissions(dbUser, user.Permissions);
+ SyncPreferences(dbUser, user.Preferences);
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
@@ -271,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users
}
}
+ private static void SyncPermissions(User dbUser, ICollection source)
+ {
+ var incoming = new Dictionary();
+ foreach (var permission in source)
+ {
+ incoming[permission.Kind] = permission.Value;
+ }
+
+ foreach (var existing in dbUser.Permissions)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ // EF only marks the row modified if the value actually differs, so an update that
+ // touches nothing but the user row - a session activity stamp - writes no children.
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Permissions.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Permissions.Add(new Permission(kind, value));
+ }
+ }
+
+ private static void SyncPreferences(User dbUser, ICollection source)
+ {
+ var incoming = new Dictionary();
+ foreach (var preference in source)
+ {
+ incoming[preference.Kind] = preference.Value;
+ }
+
+ foreach (var existing in dbUser.Preferences)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Preferences.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Preferences.Add(new Preference(kind, value));
+ }
+ }
+
internal async Task CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
{
// TODO: Remove after user item data is migrated.
diff --git a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs
similarity index 50%
rename from Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs
rename to Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs
index 60bb3fd1db..0e50ec2f47 100644
--- a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -15,9 +17,9 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
///
-/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
+/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library.
///
-[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
+[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
@@ -53,6 +55,7 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
const int ProgressInterval = 250;
var sw = Stopwatch.StartNew();
+ var newSeriesKeys = new Dictionary();
var processed = 0;
var updated = 0;
@@ -68,9 +71,10 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
}
- var oldKey = item.PresentationUniqueKey;
var newKey = item.CreatePresentationUniqueKey();
- if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
+ newSeriesKeys[item.Id] = newKey;
+
+ if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}
@@ -82,21 +86,66 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
- // Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
- // to the series by it. Re-point every child still carrying the old key in a single set-based
- // update so they stay attached without waiting for the next scan.
- if (!string.IsNullOrEmpty(oldKey))
- {
- await dbContext.BaseItems
- .Where(e => e.SeriesPresentationUniqueKey == oldKey)
- .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
- .ConfigureAwait(false);
- }
+ // Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so
+ // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than
+ // by the old key: that key can be shared by every library holding the series, so matching
+ // on it would drag the other libraries' children along.
+ await dbContext.BaseItems
+ .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id))
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
+ .ConfigureAwait(false);
updated++;
}
+
+ var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}",
+ updated,
+ series.Length,
+ updatedSeasons,
+ sw.Elapsed);
+ }
+ }
+
+ private async Task RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary newSeriesKeys, CancellationToken cancellationToken)
+ {
+ // A season's own key embeds its series' key, so it goes stale with it.
+ var seasons = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Season]
+ }).OfType().ToArray();
+
+ var updated = 0;
+
+ foreach (var season in seasons)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Without an index number the season keeps the base key, which carries no series key at all.
+ if (!season.IndexNumber.HasValue
+ || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey))
+ {
+ continue;
+ }
+
+ // Mirrors Season.CreatePresentationUniqueKey.
+ var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture);
+ if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var id = season.Id;
+ await dbContext.BaseItems
+ .Where(e => e.Id.Equals(id))
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
+ .ConfigureAwait(false);
+
+ updated++;
}
- _logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
+ return updated;
}
}
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;
+
+///
+/// Moves the views whose id used to be derived from their localized name onto their name independent id.
+///
+[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger _logger;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _configurationManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly IDbContextFactory _dbProvider;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The startup logger.
+ /// The library manager.
+ /// The server configuration manager.
+ /// The file system.
+ /// The database context factory.
+ public ConsolidateLocalizedUserViews(
+ IStartupLogger logger,
+ ILibraryManager libraryManager,
+ IServerConfigurationManager configurationManager,
+ IFileSystem fileSystem,
+ IDbContextFactory dbProvider)
+ {
+ _logger = logger;
+ _libraryManager = libraryManager;
+ _configurationManager = configurationManager;
+ _fileSystem = fileSystem;
+ _dbProvider = dbProvider;
+ }
+
+ ///
+ 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().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 candidates,
+ IReadOnlyList 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 PickSourceAsync(
+ JellyfinDbContext dbContext,
+ IReadOnlyList stale,
+ IReadOnlyList 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 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(values.Length);
+ var seen = new HashSet();
+ var touched = false;
+
+ foreach (var value in values)
+ {
+ // Clients write these in both the dashed and the plain form, so compare them parsed.
+ if (!Guid.TryParse(value, out var parsed))
+ {
+ rewritten.Add(value);
+ continue;
+ }
+
+ var 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 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;
+
///
/// The supported image extensions.
///
@@ -941,6 +945,43 @@ namespace MediaBrowser.Controller.Entities
return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
+ ///
+ /// Turns an item-by-name entity's name into a folder name every supported filesystem accepts.
+ ///
+ /// The entity's name.
+ /// The folder name.
+ public static string GetItemByNameFolderName(string name)
+ {
+ // Trim the period at the end because windows will have a hard time with that
+ var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.');
+
+ // Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be
+ // turned into a folder at all - and an entity with no folder can never be created, which
+ // leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan.
+ // Only broken provider data gets this long, but it still has to resolve to something, so
+ // keep a readable prefix and let a hash of the whole name tell two of them apart.
+ if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes)
+ {
+ return validName;
+ }
+
+ var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ var budget = MaxItemByNameFolderNameBytes - suffix.Length;
+ var length = Math.Min(validName.Length, budget);
+ while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget)
+ {
+ length--;
+ }
+
+ // Never cut a surrogate pair in half, the lone half is not a valid file name character.
+ if (length > 0 && char.IsHighSurrogate(validName[length - 1]))
+ {
+ length--;
+ }
+
+ return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix);
+ }
+
///
/// Cleans a raw name into its sortable form by applying the configured sort rules.
///
diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs
index 6ec78a270e..ef8acaef92 100644
--- a/MediaBrowser.Controller/Entities/Genre.cs
+++ b/MediaBrowser.Controller/Entities/Genre.cs
@@ -83,10 +83,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validName = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.GenrePath, validName);
}
diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs
index 14325d971a..bba5005eed 100644
--- a/MediaBrowser.Controller/Entities/Person.cs
+++ b/MediaBrowser.Controller/Entities/Person.cs
@@ -98,10 +98,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validFilename = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validFilename = normalizeName ? GetItemByNameFolderName(name) : name;
string subFolderPrefix = null;
diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs
index 9103b09a95..a944b356c8 100644
--- a/MediaBrowser.Controller/Entities/Studio.cs
+++ b/MediaBrowser.Controller/Entities/Studio.cs
@@ -78,10 +78,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
- // Trim the period at the end because windows will have a hard time with that
- var validName = normalizeName ?
- FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
- name;
+ var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.StudioPath, validName);
}
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index 3ce241aca8..1a1da84b7a 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
@@ -89,15 +90,14 @@ namespace MediaBrowser.Controller.Entities.TV
if (!string.IsNullOrEmpty(groupingKey))
{
- return AppendPreferredLanguage(groupingKey);
+ return AddLibrariesToPresentationUniqueKey(groupingKey);
}
}
return base.CreatePresentationUniqueKey();
}
- // The owning libraries are deliberately NOT part of the key.
- private string AppendPreferredLanguage(string key)
+ private string AddLibrariesToPresentationUniqueKey(string key)
{
var lang = GetPreferredMetadataLanguage();
if (!string.IsNullOrEmpty(lang))
@@ -105,7 +105,17 @@ namespace MediaBrowser.Controller.Entities.TV
key += "-" + lang;
}
- return key;
+ var folders = LibraryManager.GetCollectionFolders(this)
+ .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+
+ if (folders.Length == 0)
+ {
+ return key;
+ }
+
+ return key + "-" + string.Join('-', folders);
}
private string GetNameBasedGroupingKey()
@@ -125,20 +135,19 @@ namespace MediaBrowser.Controller.Entities.TV
{
var seriesKey = GetUniqueSeriesKey(this);
- var result = LibraryManager.GetCount(new InternalItemsQuery(user)
+ var result = LibraryManager.GetItemIds(new InternalItemsQuery(user)
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = new[] { BaseItemKind.Season },
IsVirtualItem = false,
- Limit = 0,
DtoOptions = new DtoOptions(false)
{
EnableImages = false
}
});
- return result;
+ return result.Count;
}
public override int GetRecursiveChildCount(User user)
diff --git a/MediaBrowser.Controller/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
@@ -605,6 +605,12 @@ namespace MediaBrowser.Controller.Library
/// List<System.String>.
IReadOnlyList GetPeopleNames(InternalPeopleQuery query);
+ ///
+ /// Deletes every credit that no item maps to any more.
+ ///
+ /// The number of credits that were deleted.
+ int DeleteOrphanedCredits();
+
///
/// Gets the distinct people names per item for multiple items.
///
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index cb01c1486b..8cd191c055 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -1318,7 +1318,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(canvasArgs);
}
- arg.Append(" -i file:\"").Append(subtitlePath).Append('\"');
+ arg.Append(" -i file:\"").Append(subtitlePath.EscapeProcessArgument()).Append('\"');
}
if (state.AudioStream is not null && state.AudioStream.IsExternal)
@@ -1330,7 +1330,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(' ').Append(seekAudioParam);
}
- arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"');
+ arg.Append(" -i \"").Append(state.AudioStream.Path.EscapeProcessArgument()).Append('"');
}
// Disable auto inserted SW scaler for HW decoders in case of changed resolution.
diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
index 9811241d31..15183a8806 100644
--- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
@@ -33,6 +33,12 @@ public interface IPeopleRepository
/// The list of people names matching the filter.
IReadOnlyList GetPeopleNames(InternalPeopleQuery filter);
+ ///
+ /// Deletes every credit that no item maps to any more.
+ ///
+ /// The number of credits that were deleted.
+ int DeleteOrphanedCredits();
+
///
/// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table.
///
diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs
index ef69885fcf..48fc22a0fb 100644
--- a/MediaBrowser.Controller/Providers/MetadataResult.cs
+++ b/MediaBrowser.Controller/Providers/MetadataResult.cs
@@ -16,11 +16,6 @@ namespace MediaBrowser.Controller.Providers
private List<(string Url, ImageType Type)> _remoteImages;
private List _people;
- public MetadataResult()
- {
- ResultLanguage = "en";
- }
-
public List Images
{
get => _images ??= [];
@@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers
public T Item { get; set; }
+ ///
+ /// Gets or sets the language the fetched metadata is in.
+ ///
public string ResultLanguage { get; set; }
public string Provider { get; set; }
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 12a5ab877c..fbe8afc66e 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -14,7 +14,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
@@ -160,7 +159,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
CultureInfo.InvariantCulture,
"-dump_attachment:{0} \"{1}\" ",
attachment.Index,
- EncodingUtils.NormalizePath(attachmentPath));
+ attachmentPath.EscapeProcessArgument());
missingPaths.Add(attachmentPath);
}
@@ -425,7 +424,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
"-dump_attachment:{1} \"{2}\" -i {0} {3}",
inputPath,
attachmentStreamIndex,
- EncodingUtils.NormalizePath(outputPath),
+ outputPath.EscapeProcessArgument(),
hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
int exitCode;
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
index 2daeac7343..a525dcfa62 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+using Jellyfin.Extensions;
using MediaBrowser.Model.MediaInfo;
namespace MediaBrowser.MediaEncoding.Encoder
@@ -42,7 +43,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
// If there's more than one we'll need to use the concat command
if (inputFiles.Count > 1)
{
- var files = string.Join('|', inputFiles.Select(NormalizePath));
+ var files = string.Join('|', inputFiles.Select(f => f.EscapeProcessArgument()));
return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files);
}
@@ -64,21 +65,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", path);
}
- // Quotes are valid path characters in linux and they need to be escaped here with a leading \
- path = NormalizePath(path);
+ path = path.EscapeProcessArgument();
return string.Format(CultureInfo.InvariantCulture, "{1}:\"{0}\"", path, inputPrefix);
}
-
- ///
- /// Normalizes the path.
- ///
- /// The path.
- /// System.String.
- public static string NormalizePath(string path)
- {
- // Quotes are valid path characters in linux and they need to be escaped here with a leading \
- return path.Replace("\"", "\\\"", StringComparison.Ordinal);
- }
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index e733cfd8ac..fba644b74a 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -12,6 +12,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
+using Jellyfin.Extensions;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
@@ -453,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
encodingParam = " -sub_charenc " + encodingParam;
}
- var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
+ var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath.EscapeProcessArgument(), outputPath.EscapeProcessArgument());
await ExtractSubtitlesForFile(
inputPath,
@@ -631,7 +632,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
streamIndex,
outputCodec,
outputFormatOption,
- outputPath);
+ outputPath.EscapeProcessArgument());
}
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
@@ -689,7 +690,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
streamIndex,
outputCodec,
outputFormatOption,
- outputPath);
+ outputPath.EscapeProcessArgument());
}
if (outputPaths.Count > 0)
diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
index f49b24976a..b5adee173b 100644
--- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs
+++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
@@ -141,6 +141,7 @@ public class TranscodingProfile
/// Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported.
///
[DefaultValue(false)]
+ [XmlIgnore]
[XmlAttribute("breakOnNonKeyFrames")]
[Obsolete("This is always false")]
public bool? BreakOnNonKeyFrames { get; set; }
diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
index 385a86d31c..09eba92d9e 100644
--- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
+++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
@@ -1,14 +1,16 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using System.Linq;
+using System.Text.RegularExpressions;
namespace MediaBrowser.Model.Entities;
///
/// Class ProviderIdsExtensions.
///
-public static class ProviderIdsExtensions
+public static partial class ProviderIdsExtensions
{
///
/// Case-insensitive dictionary of string representation.
@@ -20,6 +22,27 @@ public static class ProviderIdsExtensions
enumValue => enumValue.ToString(),
StringComparer.OrdinalIgnoreCase);
+ ///
+ /// The known id formats, keyed by provider name.
+ ///
+ private static readonly Dictionary> _providerIdValidators =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ [MetadataProvider.Imdb.ToString()] = value => ImdbIdRegex().IsMatch(value),
+ [MetadataProvider.Tmdb.ToString()] = IsPositiveNumber,
+ [MetadataProvider.TmdbCollection.ToString()] = IsPositiveNumber,
+ [MetadataProvider.AudioDbArtist.ToString()] = IsPositiveNumber,
+ [MetadataProvider.AudioDbAlbum.ToString()] = IsPositiveNumber,
+
+ // Every MusicBrainz id is an MBID.
+ [MetadataProvider.MusicBrainzAlbum.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzAlbumArtist.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzArtist.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzReleaseGroup.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzRecording.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzTrack.ToString()] = IsGuid
+ };
+
///
/// Checks if this instance has an id for the given provider.
///
@@ -101,6 +124,26 @@ public static class ProviderIdsExtensions
return instance.GetProviderId(provider.ToString());
}
+ ///
+ /// Checks whether a value can be an id of the given provider.
+ ///
+ /// The provider name.
+ /// The provider id.
+ /// true if the value has a plausible format for the provider; otherwise, false.
+ ///
+ /// Providers regularly hand out an id belonging to a different service, e.g. an IMDb person id in the
+ /// TMDb field. Such an id is not just useless, it also makes the owning provider fail for the item.
+ ///
+ public static bool IsValidProviderId(string? name, string? value)
+ {
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ return !_providerIdValidators.TryGetValue(name, out var isValid) || isValid(value);
+ }
+
///
/// Sets a provider id.
///
@@ -121,6 +164,14 @@ public static class ProviderIdsExtensions
return false;
}
+ name = name.Trim();
+ value = value.Trim();
+
+ if (!IsValidProviderId(name, value))
+ {
+ return false;
+ }
+
// Ensure it exists
instance.ProviderIds ??= new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -153,7 +204,6 @@ public static class ProviderIdsExtensions
/// The instance.
/// The name, this should not contain a '=' character.
/// The value.
- /// Due to how deserialization from the database works the name cannot contain '='.
public static void SetProviderId(this IHasProviderIds instance, string name, string value)
{
ArgumentNullException.ThrowIfNull(instance);
@@ -166,17 +216,27 @@ public static class ProviderIdsExtensions
throw new ArgumentException("Provider id name cannot contain '='", nameof(name));
}
- // Ensure it exists
- instance.ProviderIds ??= new Dictionary(StringComparer.OrdinalIgnoreCase);
+ instance.TrySetProviderId(name, value);
+ }
- // Match on internal MetadataProvider enum string values before adding arbitrary providers
- if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
+ ///
+ /// Replaces all provider ids, dropping the ones that cannot belong to the provider they are filed under.
+ ///
+ /// The instance.
+ /// The provider ids to set.
+ public static void SetProviderIds(this IHasProviderIds instance, IReadOnlyDictionary? providerIds)
+ {
+ ArgumentNullException.ThrowIfNull(instance);
+
+ instance.ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (providerIds is null)
{
- instance.ProviderIds[enumValue] = value;
+ return;
}
- else
+
+ foreach (var (name, value) in providerIds)
{
- instance.ProviderIds[name] = value;
+ instance.TrySetProviderId(name, value);
}
}
@@ -213,4 +273,15 @@ public static class ProviderIdsExtensions
instance.ProviderIds?.Remove(provider.ToString());
}
+
+ private static bool IsPositiveNumber(string value)
+ => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0;
+
+ private static bool IsGuid(string value)
+ => Guid.TryParse(value, CultureInfo.InvariantCulture, out _);
+
+ // An IMDb id is a type prefix (tt for titles, nm for people, co for companies, ...) followed by
+ // digits. The prefix is optional because a bare number has always been accepted for a title.
+ [GeneratedRegex(@"^(tt|nm|co|ev|ch|ni)?[0-9]+$", RegexOptions.IgnoreCase)]
+ private static partial Regex ImdbIdRegex();
}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
index 2bd2676ceb..a06de95fce 100644
--- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
@@ -204,7 +204,7 @@ public class ComicBookInfoProvider : IComicProvider
{
try
{
- return CultureInfo.GetCultureInfo(language).DisplayName;
+ return CultureInfo.GetCultureInfo(language).TwoLetterISOLanguageName;
}
catch (CultureNotFoundException)
{
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
index cfd22a850e..e3d1f544cf 100644
--- a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
@@ -52,7 +52,7 @@ public class ExternalComicInfoProvider : IComicProvider
var metadataResult = new MetadataResult { Item = book, HasMetadata = true };
ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
- ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName);
return metadataResult;
}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
index 19062452b9..4b14837441 100644
--- a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
@@ -50,7 +50,7 @@ public class InternalComicInfoProvider : IComicProvider
var metadataResult = new MetadataResult { Item = book, HasMetadata = true };
ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
- ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName);
return metadataResult;
}
diff --git a/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs
new file mode 100644
index 0000000000..92a16feaee
--- /dev/null
+++ b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs
@@ -0,0 +1,44 @@
+using System;
+
+namespace MediaBrowser.Providers.Manager;
+
+///
+/// Helpers for comparing the language of fetched metadata with the language that was requested.
+///
+internal static class MetadataLanguageUtils
+{
+ ///
+ /// Gets the language subtag of a language tag, e.g. "es" for "es-ES".
+ ///
+ /// The language tag.
+ /// The language subtag, lowercased, or null if none was given.
+ public static string? GetLanguageSubtag(string? language)
+ {
+ if (string.IsNullOrEmpty(language))
+ {
+ return null;
+ }
+
+ var separator = language.IndexOf('-', StringComparison.Ordinal);
+
+ return (separator == -1 ? language : language[..separator]).ToLowerInvariant();
+ }
+
+ ///
+ /// Determines whether a provider result can be considered to be in the requested language.
+ ///
+ /// The language the provider reported for its result, if any.
+ /// The language that was requested, if any.
+ /// true if the result is in the requested language or either language is unknown.
+ public static bool MatchesPreferredLanguage(string? resultLanguage, string? preferredLanguage)
+ {
+ // A provider that doesn't report a language cannot be judged, assume it honored the request
+ if (string.IsNullOrEmpty(resultLanguage) || string.IsNullOrEmpty(preferredLanguage))
+ {
+ return true;
+ }
+
+ // Compare on the language subtag only so that e.g. "es" matches "es-ES"
+ return string.Equals(GetLanguageSubtag(resultLanguage), GetLanguageSubtag(preferredLanguage), StringComparison.Ordinal);
+ }
+}
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 40f2775bd3..fe5285bf65 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -209,22 +209,33 @@ namespace MediaBrowser.Providers.Manager
}
}
- if (hasRefreshedMetadata && hasRefreshedImages)
+ var attemptedFetch = refreshOptions.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly
+ || refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly;
+
+ var refreshStampNeedsSaving = false;
+
+ if (hasRefreshedMetadata && hasRefreshedImages && attemptedFetch)
{
item.DateLastRefreshed = DateTime.UtcNow;
updateType |= item.OnMetadataChanged();
+
+ // A full refresh queries every provider whether or not anything looks stale. When they all
+ // come back empty the stamp is the only thing that changed, and without it nothing records
+ // that the lookup happened, so the next pass repeats the same fruitless queries forever.
+ refreshStampNeedsSaving = refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh
+ || refreshOptions.ImageRefreshMode == MetadataRefreshMode.FullRefresh;
}
- updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false);
+ updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, refreshStampNeedsSaving, metadataResult, cancellationToken).ConfigureAwait(false);
await AfterMetadataRefresh(itemOfType, refreshOptions, cancellationToken).ConfigureAwait(false);
return updateType;
- async Task SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, MetadataResult metadataResult, CancellationToken cancellationToken)
+ async Task SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, bool refreshStampNeedsSaving, MetadataResult metadataResult, CancellationToken cancellationToken)
{
// Save if changes were made, or it's never been saved before
- if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh)
+ if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh || refreshStampNeedsSaving)
{
if (item.IsFileProtocol)
{
@@ -260,21 +271,40 @@ namespace MediaBrowser.Providers.Manager
switch (lookupInfo)
{
case EpisodeInfo episodeInfo:
- episodeInfo.SeriesProviderIds = result.ProviderIds;
+ episodeInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds);
episodeInfo.ProviderIds.Clear();
break;
case SeasonInfo seasonInfo:
- seasonInfo.SeriesProviderIds = result.ProviderIds;
+ seasonInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds);
seasonInfo.ProviderIds.Clear();
break;
default:
- lookupInfo.ProviderIds = result.ProviderIds;
+ lookupInfo.SetProviderIds(result.ProviderIds);
lookupInfo.Name = result.Name;
lookupInfo.Year = result.ProductionYear;
break;
}
}
+ private static Dictionary GetValidProviderIds(IReadOnlyDictionary providerIds)
+ {
+ var validProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (providerIds is null)
+ {
+ return validProviderIds;
+ }
+
+ foreach (var (name, value) in providerIds)
+ {
+ if (ProviderIdsExtensions.IsValidProviderId(name, value))
+ {
+ validProviderIds[name] = value;
+ }
+ }
+
+ return validProviderIds;
+ }
+
protected async Task SaveItemAsync(MetadataResult result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken)
{
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
@@ -835,6 +865,7 @@ namespace MediaBrowser.Providers.Manager
}
}
+ var hasRemoteMetadata = false;
var isLocalLocked = temp.Item.IsLocked;
if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly))
{
@@ -849,6 +880,7 @@ namespace MediaBrowser.Providers.Manager
var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false);
+ hasRemoteMetadata = remoteResult.UpdateType.HasFlag(ItemUpdateType.MetadataDownload);
refreshResult.UpdateType |= remoteResult.UpdateType;
refreshResult.ErrorMessage = remoteResult.ErrorMessage;
refreshResult.Failures += remoteResult.Failures;
@@ -858,7 +890,12 @@ namespace MediaBrowser.Providers.Manager
{
if (refreshResult.UpdateType > ItemUpdateType.None)
{
- if (!options.RemoveOldMetadata)
+ // Erasing the old values is only safe when a remote provider returned something to
+ // replace them with. If every one of them failed there is no replacement, and wiping the
+ // item would turn a provider being temporarily unreachable into permanent data loss.
+ // A single failure is not enough: Identify asks for the erasure precisely because the
+ // previous match was wrong, and an unrelated provider throwing must not undo that.
+ if (!options.RemoveOldMetadata || (refreshResult.Failures > 0 && !hasRemoteMetadata))
{
// Add existing metadata to provider result if it does not exist there
MergeData(metadata, temp, [], false, false);
@@ -913,6 +950,10 @@ namespace MediaBrowser.Providers.Manager
private async Task ExecuteRemoteProviders(MetadataResult temp, string logName, bool replaceData, TIdType id, IEnumerable> providers, CancellationToken cancellationToken)
{
var refreshResult = new RefreshResult();
+ var preferredLanguage = id?.MetadataLanguage;
+
+ var overviewIsFallback = false;
+ var taglineIsFallback = false;
if (id is not null)
{
@@ -932,6 +973,28 @@ namespace MediaBrowser.Providers.Manager
{
result.Provider = provider.Name;
+ if (MetadataLanguageUtils.MatchesPreferredLanguage(result.ResultLanguage, preferredLanguage))
+ {
+ if (overviewIsFallback && !string.IsNullOrEmpty(result.Item.Overview))
+ {
+ temp.Item.Overview = null;
+ overviewIsFallback = false;
+ }
+
+ if (taglineIsFallback && !string.IsNullOrEmpty(result.Item.Tagline))
+ {
+ temp.Item.Tagline = null;
+ taglineIsFallback = false;
+ }
+ }
+ else
+ {
+ overviewIsFallback |= string.IsNullOrEmpty(temp.Item.Overview) && !string.IsNullOrEmpty(result.Item.Overview);
+ taglineIsFallback |= string.IsNullOrEmpty(temp.Item.Tagline) && !string.IsNullOrEmpty(result.Item.Tagline);
+ }
+
+ LogInvalidProviderIds(result, providerName, logName);
+
MergeData(result, temp, [], replaceData, false);
MergeNewData(temp.Item, id);
@@ -957,6 +1020,58 @@ namespace MediaBrowser.Providers.Manager
return refreshResult;
}
+ ///
+ /// Reports the ids a provider returned that cannot belong to the provider they are filed under.
+ ///
+ ///
+ /// The ids are dropped when merging, this names the provider that produced them so the source of a
+ /// recurring bad id can be found.
+ ///
+ private void LogInvalidProviderIds(MetadataResult result, string providerName, string logName)
+ {
+ if (!Logger.IsEnabled(LogLevel.Debug))
+ {
+ return;
+ }
+
+ LogInvalidProviderIds(result.Item?.ProviderIds, providerName, logName, null);
+
+ if (result.People is null)
+ {
+ return;
+ }
+
+ foreach (var person in result.People)
+ {
+ LogInvalidProviderIds(person.ProviderIds, providerName, logName, person.Name);
+ }
+ }
+
+ private void LogInvalidProviderIds(IReadOnlyDictionary providerIds, string providerName, string logName, string personName)
+ {
+ if (providerIds is null)
+ {
+ return;
+ }
+
+ foreach (var (key, value) in providerIds)
+ {
+ if (ProviderIdsExtensions.IsValidProviderId(key, value))
+ {
+ continue;
+ }
+
+ if (personName is null)
+ {
+ Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Item}", key, value, providerName, logName);
+ }
+ else
+ {
+ Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Person} of {Item}", key, value, providerName, personName, logName);
+ }
+ }
+ }
+
private void MergeNewData(TItemType source, TIdType lookupInfo)
{
// Copy new provider id's that may have been obtained
@@ -964,8 +1079,18 @@ namespace MediaBrowser.Providers.Manager
{
var key = providerId.Key;
- // Don't replace existing Id's.
- lookupInfo.ProviderIds.TryAdd(key, providerId.Value);
+ if (!ProviderIdsExtensions.IsValidProviderId(key, providerId.Value))
+ {
+ continue;
+ }
+
+ // Don't replace existing Id's, unless the one already there is unusable - handing that
+ // one to the providers that have yet to run is what makes them fail.
+ if (!lookupInfo.ProviderIds.TryGetValue(key, out var existingId)
+ || !ProviderIdsExtensions.IsValidProviderId(key, existingId))
+ {
+ lookupInfo.ProviderIds[key] = providerId.Value;
+ }
}
}
@@ -1104,6 +1229,9 @@ namespace MediaBrowser.Providers.Manager
if (!lockedFields.Contains(MetadataField.Cast))
{
+ RemoveInvalidProviderIds(sourceResult.People);
+ RemoveInvalidProviderIds(targetResult.People);
+
if (replaceData || targetResult.People is null || targetResult.People.Count == 0)
{
targetResult.People = sourceResult.People;
@@ -1175,15 +1303,31 @@ namespace MediaBrowser.Providers.Manager
{
var key = id.Key;
- // Don't replace existing Id's.
- if (replaceData)
+ // An id that cannot belong to the provider it is filed under only breaks that provider on
+ // the next refresh, so never let one in - not even when replacing all metadata.
+ if (!ProviderIdsExtensions.IsValidProviderId(key, id.Value))
+ {
+ continue;
+ }
+
+ // Don't replace existing Id's, unless the stored one is unusable - that one is the bad
+ // match the refresh is meant to repair.
+ if (replaceData
+ || !target.ProviderIds.TryGetValue(key, out var existingId)
+ || !ProviderIdsExtensions.IsValidProviderId(key, existingId))
{
target.ProviderIds[key] = id.Value;
}
- else
- {
- target.ProviderIds.TryAdd(key, id.Value);
- }
+ }
+
+ // A bad id no provider offered a replacement for still has to go, otherwise the item keeps
+ // failing the same way on every refresh.
+ foreach (var key in target.ProviderIds
+ .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value))
+ .Select(id => id.Key)
+ .ToArray())
+ {
+ target.ProviderIds.Remove(key);
}
if (replaceData || !target.CriticRating.HasValue)
@@ -1251,6 +1395,32 @@ namespace MediaBrowser.Providers.Manager
}
}
+ private static void RemoveInvalidProviderIds(IReadOnlyList people)
+ {
+ if (people is null)
+ {
+ return;
+ }
+
+ foreach (var person in people)
+ {
+ if (person.ProviderIds is null || person.ProviderIds.Count == 0)
+ {
+ continue;
+ }
+
+ var invalidKeys = person.ProviderIds
+ .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value))
+ .Select(id => id.Key)
+ .ToArray();
+
+ foreach (var key in invalidKeys)
+ {
+ person.ProviderIds.Remove(key);
+ }
+ }
+ }
+
private static void MergePeople(IReadOnlyList source, IReadOnlyList target)
{
var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase);
diff --git a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs
index d3fce37c71..2923dd3290 100644
--- a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs
+++ b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs
@@ -23,11 +23,11 @@ namespace MediaBrowser.Providers.Music
public static string? GetReleaseGroupId(this AlbumInfo info)
{
- var id = info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup);
+ var id = MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup));
if (string.IsNullOrEmpty(id))
{
- return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))
+ return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -36,11 +36,11 @@ namespace MediaBrowser.Providers.Music
public static string? GetReleaseId(this AlbumInfo info)
{
- var id = info.GetProviderId(MetadataProvider.MusicBrainzAlbum);
+ var id = MusicBrainzId(MetadataProvider.MusicBrainzAlbum, info.GetProviderId(MetadataProvider.MusicBrainzAlbum));
if (string.IsNullOrEmpty(id))
{
- return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbum))
+ return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbum, i.GetProviderId(MetadataProvider.MusicBrainzAlbum)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -50,15 +50,17 @@ namespace MediaBrowser.Providers.Music
public static string? GetMusicBrainzArtistId(this AlbumInfo info)
{
info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id);
+ id = MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, id);
if (string.IsNullOrEmpty(id))
{
info.ArtistProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out id);
+ id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id);
}
if (string.IsNullOrEmpty(id))
{
- return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))
+ return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -68,14 +70,21 @@ namespace MediaBrowser.Providers.Music
public static string? GetMusicBrainzArtistId(this ArtistInfo info)
{
info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id);
+ id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id);
if (string.IsNullOrEmpty(id))
{
- return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))
+ return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
return id;
}
+
+ ///
+ /// Returns the id if it can be an id of the given provider, otherwise null.
+ ///
+ private static string? MusicBrainzId(MetadataProvider provider, string? id)
+ => ProviderIdsExtensions.IsValidProviderId(provider.ToString(), id) ? id : null;
}
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
index 0acd44afbe..1903adfbdd 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
@@ -21,6 +21,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
+using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Music;
namespace MediaBrowser.Providers.Plugins.AudioDb
@@ -77,7 +78,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
{
result.Item = new MusicAlbum();
result.HasMetadata = true;
- ProcessResult(result.Item, obj.album[0], info.MetadataLanguage);
+ ProcessResult(result, obj.album[0], info.MetadataLanguage);
}
}
}
@@ -85,8 +86,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
return result;
}
- private void ProcessResult(MusicAlbum item, Album result, string preferredLanguage)
+ private void ProcessResult(MetadataResult metadataResult, Album result, string preferredLanguage)
{
+ var item = metadataResult.Item;
+
if (Plugin.Instance.Configuration.ReplaceAlbumName && !string.IsNullOrWhiteSpace(result.strAlbum))
{
item.Album = result.strAlbum;
@@ -113,43 +116,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
item.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, result.strMusicBrainzArtistID);
item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, result.strMusicBrainzID);
- string overview = null;
-
- if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionDE;
- }
- else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionFR;
- }
- else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionNL;
- }
- else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionRU;
- }
- else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionIT;
- }
- else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strDescriptionPT;
- }
+ var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage);
+ var overview = GetDescription(result, language);
if (string.IsNullOrWhiteSpace(overview))
{
overview = string.IsNullOrWhiteSpace(result.strDescriptionEN)
? result.strDescription
: result.strDescriptionEN;
+
+ // The description is not in the requested language, mark it as English so it does not
+ // block a provider further down the list that can serve the requested language
+ metadataResult.ResultLanguage = "en";
+ }
+ else
+ {
+ metadataResult.ResultLanguage = language;
}
item.Overview = (overview ?? string.Empty).StripHtml();
}
+ private static string GetDescription(Album result, string language)
+ => language switch
+ {
+ "de" => result.strDescriptionDE,
+ "en" => result.strDescriptionEN,
+ "es" => result.strDescriptionES,
+ "fr" => result.strDescriptionFR,
+ "he" => result.strDescriptionIL,
+ "hu" => result.strDescriptionHU,
+ "it" => result.strDescriptionIT,
+ "ja" => result.strDescriptionJP,
+ "nl" => result.strDescriptionNL,
+ "no" or "nb" or "nn" => result.strDescriptionNO,
+ "pl" => result.strDescriptionPL,
+ "pt" => result.strDescriptionPT,
+ "ru" => result.strDescriptionRU,
+ "sv" => result.strDescriptionSE,
+ "zh" => result.strDescriptionCN,
+ _ => null
+ };
+
internal async Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken)
{
var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId);
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
index c4f4833857..2d9fe4448f 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs
@@ -22,6 +22,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
+using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Music;
namespace MediaBrowser.Providers.Plugins.AudioDb
@@ -148,7 +149,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
{
result.Item = new MusicArtist();
result.HasMetadata = true;
- ProcessResult(result.Item, artist, info.MetadataLanguage);
+ ProcessResult(result, artist, info.MetadataLanguage);
}
return result;
@@ -193,8 +194,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
return null;
}
- private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage)
+ private void ProcessResult(MetadataResult metadataResult, Artist result, string preferredLanguage)
{
+ var item = metadataResult.Item;
+
if (!string.IsNullOrWhiteSpace(result.strWebsite))
{
item.HomePageUrl = result.strWebsite;
@@ -229,43 +232,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist);
item.SetProviderId(MetadataProvider.MusicBrainzArtist, result.strMusicBrainzID);
- string overview = null;
-
- if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyDE;
- }
- else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyFR;
- }
- else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyNL;
- }
- else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyRU;
- }
- else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyIT;
- }
- else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase))
- {
- overview = result.strBiographyPT;
- }
+ var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage);
+ var overview = GetBiography(result, language);
if (string.IsNullOrWhiteSpace(overview))
{
overview = string.IsNullOrWhiteSpace(result.strBiographyEN)
? result.strBiography
: result.strBiographyEN;
+
+ // The biography is not in the requested language, mark it as English so it does not
+ // block a provider further down the list that can serve the requested language
+ metadataResult.ResultLanguage = "en";
+ }
+ else
+ {
+ metadataResult.ResultLanguage = language;
}
item.Overview = (overview ?? string.Empty).StripHtml();
}
+ private static string GetBiography(Artist result, string language)
+ => language switch
+ {
+ "de" => result.strBiographyDE,
+ "en" => result.strBiographyEN,
+ "es" => result.strBiographyES,
+ "fr" => result.strBiographyFR,
+ "he" => result.strBiographyIL,
+ "hu" => result.strBiographyHU,
+ "it" => result.strBiographyIT,
+ "ja" => result.strBiographyJP,
+ "nl" => result.strBiographyNL,
+ "no" or "nb" or "nn" => result.strBiographyNO,
+ "pl" => result.strBiographyPL,
+ "pt" => result.strBiographyPT,
+ "ru" => result.strBiographyRU,
+ "sv" => result.strBiographySE,
+ "zh" => result.strBiographyCN,
+ _ => null
+ };
+
internal async Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken)
{
var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs
index ccff31ebaa..437a997c11 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs
@@ -44,7 +44,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var result = new MetadataResult
{
Item = new Episode(),
- QueriedById = true
+ QueriedById = true,
+ // OMDb is not localized, everything it returns is English
+ ResultLanguage = "en"
};
// Allowing this will dramatically increase scan times
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
index e84f1359b7..7b245ea5a7 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
@@ -218,7 +218,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var result = new MetadataResult
{
Item = new T(),
- QueriedById = true
+ QueriedById = true,
+ // OMDb is not localized, everything it returns is English
+ ResultLanguage = "en"
};
var imdbId = info.GetProviderId(MetadataProvider.Imdb);
diff --git a/MediaBrowser.Providers/Plugins/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
/// Provider for OMDB service.
public class OmdbProvider
{
+ /// Generational suffixes that OMDb separates from the name with a comma.
+ 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);
+ /// Adds the people from a comma separated OMDb credit list.
+ /// The item type.
+ /// The metadata result to add the people to.
+ /// The comma separated OMDb credit list.
+ /// The kind of person each credit describes.
+ internal static void AddPeople(MetadataResult itemResult, string credits, PersonKind type)
+ where T : BaseItem
+ {
+ if (string.IsNullOrWhiteSpace(credits))
+ {
+ return;
}
- if (!string.IsNullOrWhiteSpace(result.Writer))
- {
- var person = new PersonInfo
- {
- Name = result.Writer.Trim(),
- Type = PersonKind.Writer
- };
+ var names = new List();
- itemResult.AddPerson(person);
+ foreach (var credit in SplitCredits(credits))
+ {
+ // 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)
+ {
+ 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;
+ }
+
+ 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
+ });
+ }
+ }
- itemResult.AddPerson(person);
+ // 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 SplitCredits(string credits)
+ {
+ var depth = 0;
+ var start = 0;
+
+ 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/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
index 78be5804e3..23f8d89c67 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -56,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
///
public async Task> GetImages(BaseItem item, CancellationToken cancellationToken)
{
- var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ item.TryGetTmdbId(out var tmdbId);
if (tmdbId <= 0)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
index a7bba2d539..0a75b71264 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -42,7 +41,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
///
public async Task> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken)
{
- var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ searchInfo.TryGetTmdbId(out var tmdbId);
var language = searchInfo.MetadataLanguage;
if (tmdbId > 0)
@@ -97,7 +96,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
///
public async Task> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken)
{
- var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ info.TryGetTmdbId(out var tmdbId);
var language = info.MetadataLanguage;
// We don't already have an Id, need to fetch it
@@ -115,7 +114,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
}
}
- var result = new MetadataResult();
+ var result = new MetadataResult
+ {
+ ResultLanguage = language
+ };
if (tmdbId > 0)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
index b188f5deb4..e686577311 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -61,7 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var language = item.GetPreferredMetadataLanguage();
var countryCode = item.GetPreferredMetadataCountryCode();
- var movieTmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ item.TryGetTmdbId(out var movieTmdbId);
if (movieTmdbId <= 0)
{
var movieImdbId = item.GetProviderId(MetadataProvider.Imdb);
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
index 8811a1787a..ef952082da 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
@@ -54,11 +54,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
///
public async Task> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken)
{
- if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var id))
+ if (searchInfo.TryGetTmdbId(out var tmdbId))
{
var movie = await _tmdbClientManager
.GetMovieAsync(
- int.Parse(id, CultureInfo.InvariantCulture),
+ tmdbId,
searchInfo.MetadataLanguage,
TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode),
searchInfo.MetadataCountryCode,
@@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
}
IReadOnlyList? movieResults = null;
- if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out id))
+ if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var id))
{
var result = await _tmdbClientManager.FindByExternalIdAsync(
id,
@@ -151,11 +151,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
///
public async Task> GetMetadata(MovieInfo info, CancellationToken cancellationToken)
{
- var tmdbId = info.GetProviderId(MetadataProvider.Tmdb);
+ // A stored id that is not a TMDb id is treated as no id, so the search below can repair it
+ // rather than the lookup failing for as long as the bad id stays on the item.
+ info.TryGetTmdbId(out var tmdbId);
var imdbId = info.GetProviderId(MetadataProvider.Imdb);
var config = Plugin.Instance.Configuration;
- if (string.IsNullOrEmpty(tmdbId) && string.IsNullOrEmpty(imdbId))
+ if (tmdbId <= 0 && string.IsNullOrEmpty(imdbId))
{
// ParseName is required here.
// Caller provides the filename with extension stripped and NOT the parsed filename
@@ -166,26 +168,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
if (searchResults?.Count > 0)
{
- tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture);
+ tmdbId = searchResults[0].Id;
}
}
- if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId))
+ if (tmdbId <= 0 && !string.IsNullOrEmpty(imdbId))
{
var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (movieResultFromImdbId?.MovieResults?.Count > 0)
{
- tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture);
+ tmdbId = movieResultFromImdbId.MovieResults[0].Id;
}
}
- if (string.IsNullOrEmpty(tmdbId))
+ if (tmdbId <= 0)
{
return new MetadataResult();
}
var movieResult = await _tmdbClientManager
- .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
+ .GetMovieAsync(tmdbId, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (movieResult is null)
@@ -208,7 +210,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
Item = movie
};
- movie.SetProviderId(MetadataProvider.Tmdb, tmdbId);
+ movie.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString(CultureInfo.InvariantCulture));
movie.TrySetProviderId(MetadataProvider.Imdb, movieResult.ImdbId);
if (movieResult.BelongsToCollection is not null)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
index 33888ddf4f..d38614811c 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -54,14 +53,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
{
var person = (Person)item;
- if (!person.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId))
+ if (!person.TryGetTmdbId(out var personTmdbId))
{
return Enumerable.Empty();
}
var language = item.GetPreferredMetadataLanguage();
var countryCode = item.GetPreferredMetadataCountryCode();
- var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, countryCode, cancellationToken).ConfigureAwait(false);
+ var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, language, countryCode, cancellationToken).ConfigureAwait(false);
if (personResult?.Images?.Profiles is null)
{
return Enumerable.Empty();
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
index 64ab98b262..695f347a9a 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
@@ -37,9 +36,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
///
public async Task> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken)
{
- if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId))
+ if (searchInfo.TryGetTmdbId(out var personTmdbId))
{
- var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
+ var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (personResult is not null)
{
@@ -89,7 +88,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
///
public async Task> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken)
{
- var personTmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ // A person can carry another provider's id under the TMDb key, which is no more usable here
+ // than no id at all, so both take the search path and get the stored id repaired.
+ info.TryGetTmdbId(out var personTmdbId);
// We don't already have an Id, need to fetch it
if (personTmdbId <= 0)
@@ -101,7 +102,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
}
}
- var result = new MetadataResult();
+ var result = new MetadataResult
+ {
+ ResultLanguage = info.MetadataLanguage
+ };
if (personTmdbId > 0)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
index 7ae54cdcd3..1f8c87397d 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -56,9 +54,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var episode = (Controller.Entities.TV.Episode)item;
var series = episode.Series;
- var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ var seriesTmdbId = 0;
- if (series is null || seriesTmdbId <= 0)
+ if (series?.TryGetTmdbId(out seriesTmdbId) != true)
{
return Enumerable.Empty();
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
index 21b822c97c..8172ab14df 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
@@ -91,8 +91,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? tmdbId);
- var seriesTmdbId = Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture);
- if (seriesTmdbId <= 0)
+ if (!TmdbUtils.TryParseTmdbId(tmdbId, out var seriesTmdbId))
{
return metadataResult;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
index 5b2f0d26e4..bc44d0266d 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -57,9 +55,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var season = (Season)item;
var series = season?.Series;
- var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ var seriesTmdbId = 0;
- if (seriesTmdbId <= 0 || season?.IndexNumber is null)
+ if (season?.IndexNumber is null || series?.TryGetTmdbId(out seriesTmdbId) != true)
{
return Enumerable.Empty();
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
index 9c41d64253..9b8803f171 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -41,20 +40,23 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
///
public async Task> GetMetadata(SeasonInfo info, CancellationToken cancellationToken)
{
- var result = new MetadataResult();
+ var result = new MetadataResult
+ {
+ ResultLanguage = info.MetadataLanguage
+ };
var config = Plugin.Instance.Configuration;
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId);
var seasonNumber = info.IndexNumber;
- if (string.IsNullOrWhiteSpace(seriesTmdbId) || !seasonNumber.HasValue)
+ if (!seasonNumber.HasValue || !TmdbUtils.TryParseTmdbId(seriesTmdbId, out var seriesId))
{
return result;
}
var seasonResult = await _tmdbClientManager
- .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
+ .GetSeasonAsync(seriesId, seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (seasonResult is null)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
index f2e7d0c6e4..dc4f860604 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -57,9 +55,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
///
public async Task> GetImages(BaseItem item, CancellationToken cancellationToken)
{
- var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
-
- if (string.IsNullOrEmpty(tmdbId))
+ if (!item.TryGetTmdbId(out var tmdbId))
{
return Enumerable.Empty();
}
@@ -68,7 +64,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
var series = await _tmdbClientManager
- .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, null, cancellationToken)
+ .GetSeriesAsync(tmdbId, null, null, null, cancellationToken)
.ConfigureAwait(false);
if (series?.Images is null)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index 9bb15ca479..6163e20194 100755
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -54,10 +54,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
///
public async Task> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
{
- if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId))
+ if (searchInfo.TryGetTmdbId(out var tmdbId))
{
var series = await _tmdbClientManager
- .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
+ .GetSeriesAsync(tmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (series is not null)
@@ -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 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 7e6b9beee9..44a2f7291e 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -2,10 +2,14 @@ using System;
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
{
@@ -62,6 +66,33 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
[GeneratedRegex(@"[\W_-[·]]+")]
private static partial Regex NonWordRegex();
+ ///
+ /// Gets the TMDb id of an item, if it has one TMDb can be queried with.
+ ///
+ /// The item.
+ /// The TMDb id.
+ /// true if the item has a usable TMDb id; otherwise, false.
+ public static bool TryGetTmdbId(this IHasProviderIds instance, out int tmdbId)
+ {
+ instance.TryGetProviderId(MetadataProvider.Tmdb, out var value);
+
+ return TryParseTmdbId(value, out tmdbId);
+ }
+
+ ///
+ /// Parses a TMDb id.
+ ///
+ /// The stored id.
+ /// The TMDb id.
+ /// true if the value is a usable TMDb id; otherwise, false.
+ public static bool TryParseTmdbId(string? value, out int tmdbId)
+ {
+ // Another provider can have filed one of its own ids under the TMDb key, e.g. an IMDb person
+ // id. Reporting that as "no id" lets the caller fall back to a search and repair the id,
+ // instead of throwing on every refresh of the item.
+ return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out tmdbId) && tmdbId > 0;
+ }
+
///
/// Cleans the name according to TMDb requirements.
///
@@ -101,6 +132,100 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
return PersonKind.Unknown;
}
+ ///
+ /// Maps an aggregated TMDb cast list, whose entries hold every role their member played.
+ ///
+ /// The aggregated cast list, or null.
+ /// The configuration deciding how much of the cast to keep.
+ /// Resolves a profile path into an absolute image url.
+ /// One credit per role played.
+ internal static IEnumerable MapAggregateCast(
+ IReadOnlyList? cast,
+ PluginConfiguration config,
+ Func 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);
+ }
+ }
+ }
+
+ ///
+ /// Maps a TMDb cast list whose entries hold the one character their member is credited for.
+ ///
+ /// The cast list, or null.
+ /// The configuration deciding how much of the cast to keep.
+ /// Resolves a profile path into an absolute image url.
+ /// One credit per cast entry.
+ internal static IEnumerable MapCast(
+ IReadOnlyList? cast,
+ PluginConfiguration config,
+ Func 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 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;
+ }
+
///
/// Determines whether a video is a trailer.
///
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index b350f482c3..803fab538f 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -364,7 +364,7 @@ public class SeriesMetadataService : MetadataService
foreach (var episode in episodes)
{
var season = seasons.FirstOrDefault(i => i.IndexNumber == episode.ParentIndexNumber);
- if (season is null || episode.SeasonId.Equals(season.Id))
+ if (season is null || (episode.SeasonId.Equals(season.Id) && episode.ParentId.Equals(season.Id)))
{
continue;
}
@@ -372,6 +372,11 @@ public class SeriesMetadataService : MetadataService
// Assign the correct season id and name to episode.
episode.SeasonId = season.Id;
episode.SeasonName = season.Name;
+
+ // We need to set ParentId here for episodes in virtual seasons (e.g., flat structures), otherwise it retains the
+ // ParentId from the series.
+ episode.SetParent(season);
+
await episode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
index bfd0fac34a..b821476390 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
@@ -8,7 +8,7 @@ using Jellyfin.Database.Implementations.MatchCriteria;
namespace Jellyfin.Database.Implementations;
///
-/// Provides methods for querying item hierarchies using iterative traversal.
+/// Provides methods for querying item hierarchies.
/// Uses AncestorIds and LinkedChildren tables for parent-child traversal.
///
public static class DescendantQueryHelper
@@ -32,11 +32,36 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
- var descendants = TraverseHierarchyDown(context, [parentId]);
+ return AllDescendants(context, [parentId])
+ .Where(e => !e.Equals(parentId))
+ .Distinct();
+ }
- descendants.Remove(parentId);
+ ///
+ /// Gets all descendant IDs for multiple parent items in a single traversal.
+ /// Traverses AncestorIds and LinkedChildren, like , but resolves
+ /// the roots once for all seeds instead of once per seed.
+ ///
+ /// Database context.
+ /// Parent item IDs.
+ /// Set of all descendant item IDs (excluding the parent IDs themselves).
+ public static HashSet GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList parentIds)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(parentIds);
- return descendants.AsQueryable();
+ if (parentIds.Count == 0)
+ {
+ return [];
+ }
+
+ var descendants = AllDescendants(context, parentIds)
+ .Distinct()
+ .ToHashSet();
+
+ descendants.ExceptWith(parentIds);
+
+ return descendants;
}
///
@@ -51,11 +76,9 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
- var descendants = TraverseHierarchyDownOwned(context, [parentId]);
-
- descendants.Remove(parentId);
-
- return descendants.AsQueryable();
+ return ClosureDescendants(context, [parentId])
+ .Where(e => !e.Equals(parentId))
+ .Distinct();
}
///
@@ -76,11 +99,11 @@ public static class DescendantQueryHelper
return [];
}
- var seedSet = new HashSet(parentIds);
- var descendants = TraverseHierarchyDownOwned(context, seedSet);
+ var descendants = ClosureDescendants(context, parentIds)
+ .Distinct()
+ .ToHashSet();
- // Remove the seed IDs — callers want only descendants
- descendants.ExceptWith(seedSet);
+ descendants.ExceptWith(parentIds);
return descendants;
}
@@ -96,28 +119,106 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(criteria);
- var matchingItemIds = criteria switch
+
+ // Both sides of a version group can hold a folder a caller would see as matching: the
+ // alternate carries its own AncestorIds rows and may sit in a different library than the
+ // primary it is reported against, and the primary is the item that becomes visible.
+ var reportedItemIds = MatchingMediaOwnerIds(context, criteria)
+ .Concat(GetPrimaryVersionIdsMatching(context, criteria))
+ .Distinct();
+
+ // One hop up the closure covers every ancestor level.
+ var hierarchyAncestors = context.AncestorIds
+ .Where(e => reportedItemIds.Contains(e.ItemId))
+ .Select(e => e.ParentItemId);
+
+ var linkParents = ResolveLinkParents(context, reportedItemIds, hierarchyAncestors);
+
+ // Read back as a sub-select so the result stays composable. Off the primary key, which is one
+ // row per id: LinkedChildren would yield one row per link and lean on the outer Distinct.
+ var linkedParents = context.BaseItems
+ .WhereOneOrMany(linkParents, e => e.Id)
+ .Select(e => e.Id);
+
+ var linkedParentAncestors = context.AncestorIds
+ .WhereOneOrMany(linkParents, e => e.ItemId)
+ .Select(e => e.ParentItemId);
+
+ // The chain an item carries stops at its collection folders, so this hop crosses that seam to
+ // the UserRootFolder above them. One statement for both sides beats a sub-select per side.
+ var seamAncestors = context.AncestorIds
+ .Where(e => hierarchyAncestors.Contains(e.ItemId) || linkedParentAncestors.Contains(e.ItemId))
+ .Select(e => e.ParentItemId);
+
+ return hierarchyAncestors
+ .Concat(linkedParents)
+ .Concat(linkedParentAncestors)
+ .Concat(seamAncestors)
+ .Distinct();
+ }
+
+ ///
+ /// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the
+ /// criteria.
+ ///
+ /// Database context.
+ /// The matching criteria to apply.
+ /// Queryable of primary version item IDs.
+ ///
+ /// For callers that already test an item's own media with their own indexed predicate: this covers
+ /// exactly what such a predicate misses, and the filtered PrimaryVersionId index keeps it to the few
+ /// items that have versions at all.
+ ///
+ public static IQueryable GetPrimaryVersionIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(criteria);
+
+ // Anchored on the alternates rather than on the matches: "has a primary version" is served by
+ // the partial PrimaryVersionId index, which holds only the few items that are second files, so
+ // this costs a seek each into the stream index instead of a second pass over every stream row.
+ var alternates = context.BaseItems.Where(v => v.PrimaryVersionId.HasValue);
+
+ if (criteria is HasChapterImages)
{
- HasSubtitles => context.MediaStreamInfos
- .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle)
- .Select(ms => ms.ItemId)
- .Distinct()
- .ToHashSet(),
- HasChapterImages => context.Chapters
+ return alternates
+ .Where(v => context.Chapters.Any(c => c.ItemId.Equals(v.Id) && c.ImagePath != null))
+ .Select(v => v.PrimaryVersionId!.Value);
+ }
+
+ var matchingStreams = MatchingMediaStreams(context, criteria);
+
+ return alternates
+ .Where(v => matchingStreams.Any(ms => ms.ItemId.Equals(v.Id)))
+ .Select(v => v.PrimaryVersionId!.Value);
+ }
+
+ // The ids of the items whose own media matches. Kept to the stream and chapter tables so their
+ // covering indexes answer this outright: projecting the BaseItems navigation instead would add a
+ // primary-key lookup per stream row rather than one per matching item, and the leading key of both
+ // indexes leaves the ids already grouped, so the Distinct costs no sort.
+ private static IQueryable MatchingMediaOwnerIds(JellyfinDbContext context, FolderMatchCriteria criteria)
+ => criteria is HasChapterImages
+ ? context.Chapters
.Where(c => c.ImagePath != null)
.Select(c => c.ItemId)
.Distinct()
- .ToHashSet(),
- HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m),
+ : MatchingMediaStreams(context, criteria)
+ .Select(ms => ms.ItemId)
+ .Distinct();
+
+ // The stream rows a criteria matches. One definition, so the owner projection and the alternate
+ // projection cannot drift apart despite reading it from opposite ends.
+ private static IQueryable MatchingMediaStreams(JellyfinDbContext context, FolderMatchCriteria criteria)
+ => criteria switch
+ {
+ HasSubtitles => context.MediaStreamInfos
+ .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle),
+ HasMediaStreamType m => GetMatchingMediaStreams(context, m),
_ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}")
};
- var ancestors = TraverseHierarchyUp(context, matchingItemIds);
-
- return ancestors.AsQueryable();
- }
-
- private static HashSet GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
+ private static IQueryable GetMatchingMediaStreams(JellyfinDbContext context, HasMediaStreamType criteria)
{
var query = context.MediaStreamInfos
.Where(ms => ms.StreamType == criteria.StreamType
@@ -130,130 +231,140 @@ public static class DescendantQueryHelper
query = query.Where(ms => ms.IsExternal == isExternal);
}
- return query.Select(ms => ms.ItemId).Distinct().ToHashSet();
+ return query;
}
- ///
- /// Traverses DOWN the hierarchy from parent folders to find all descendants.
- ///
- private static HashSet TraverseHierarchyDown(JellyfinDbContext context, ICollection startIds)
+ private static IQueryable AllDescendants(JellyfinDbContext context, IReadOnlyList parentIds)
{
- var visited = new HashSet(startIds);
- var folderStack = new HashSet(startIds);
+ var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentIds);
- while (folderStack.Count != 0)
+ var linkedDescendants = context.LinkedChildren
+ .WhereOneOrMany(linkRoots, e => e.ParentId)
+ .Select(e => e.ChildId);
+
+ return ClosureDescendants(context, closureRoots)
+ .Concat(linkedDescendants);
+ }
+
+ private static IQueryable ClosureDescendants(JellyfinDbContext context, IReadOnlyList roots)
+ {
+ var direct = context.AncestorIds
+ .WhereOneOrMany(roots, e => e.ParentItemId)
+ .Select(e => e.ItemId);
+
+ // An item carries its own chain plus its collection folders, never the UserRootFolder.
+ var indirect = context.AncestorIds
+ .Where(e => direct.Contains(e.ParentItemId))
+ .Select(e => e.ItemId);
+
+ return direct.Concat(indirect);
+ }
+
+ // Resolves the folders whose linked children lead, at any depth, to a matching item.
+ private static List ResolveLinkParents(JellyfinDbContext context, IQueryable matchingItemIds, IQueryable ancestorsOfMatches)
+ {
+ // An alternate version is a second file for the item that links it, not a child of it, so that
+ // edge is not walked. It is also the one link a non-folder owns, and there is one per remuxed
+ // movie: walking it would swell this list from the BoxSet and Playlist count to the item count,
+ // and the list is bound into every statement the returned queryable is embedded in.
+ var containerLinks = context.LinkedChildren
+ .Where(e => e.ChildType != LinkedChildType.LocalAlternateVersion
+ && e.ChildType != LinkedChildType.LinkedAlternateVersion);
+
+ // A link sits above the closure and above another link alike, so the hop repeats until nothing
+ // new turns up.
+ var resolved = containerLinks
+ .Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId))
+ .Select(e => e.ParentId)
+ .Distinct()
+ .ToHashSet();
+
+ var frontier = resolved.ToList();
+
+ while (frontier.Count != 0)
{
- var currentFolders = folderStack.ToArray();
- folderStack.Clear();
+ var containingFolders = context.AncestorIds
+ .WhereOneOrMany(frontier, e => e.ItemId)
+ .Select(e => e.ParentItemId);
- var directChildren = context.AncestorIds
- .WhereOneOrMany(currentFolders, e => e.ParentItemId)
- .Select(e => e.ItemId)
+ var directLinkParents = containerLinks
+ .WhereOneOrMany(frontier, e => e.ChildId)
+ .Select(e => e.ParentId);
+
+ var indirectLinkParents = containerLinks
+ .Where(e => containingFolders.Contains(e.ChildId))
+ .Select(e => e.ParentId);
+
+ var next = directLinkParents
+ .Concat(indirectLinkParents)
+ .Distinct()
.ToArray();
- var linkedChildren = context.LinkedChildren
- .WhereOneOrMany(currentFolders, e => e.ParentId)
- .Select(e => e.ChildId)
- .ToArray();
-
- var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray();
-
- if (allChildren.Length == 0)
+ frontier = [];
+ foreach (var id in next)
{
- break;
- }
-
- var childFolders = context.BaseItems
- .WhereOneOrMany(allChildren, e => e.Id)
- .Where(e => e.IsFolder)
- .Select(e => e.Id)
- .ToHashSet();
-
- foreach (var childId in allChildren)
- {
- if (visited.Add(childId) && childFolders.Contains(childId))
+ // Cyclic links terminate on the resolved set.
+ if (resolved.Add(id))
{
- folderStack.Add(childId);
+ frontier.Add(id);
}
}
}
- return visited;
+ return [.. resolved];
}
- ///
- /// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren.
- ///
- private static HashSet TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection startIds)
+ // Resolves the roots the descendant sub-selects are anchored on: those contributing their closure,
+ // and those contributing their linked children.
+ private static (List ClosureRoots, List LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, IReadOnlyList parentIds)
{
- var visited = new HashSet(startIds);
- var folderStack = new HashSet(startIds);
+ var visited = new HashSet(parentIds);
+ var closureRoots = visited.ToList();
+ var linkRoots = visited.ToList();
+ var frontier = visited.ToList();
- while (folderStack.Count != 0)
+ while (frontier.Count != 0)
{
- var currentFolders = folderStack.ToArray();
- folderStack.Clear();
+ var closureIds = ClosureDescendants(context, frontier);
- var directChildren = context.AncestorIds
- .WhereOneOrMany(currentFolders, e => e.ParentItemId)
- .Select(e => e.ItemId)
- .ToArray();
+ var linkedIds = context.LinkedChildren
+ .WhereOneOrMany(frontier, e => e.ParentId)
+ .Select(e => e.ChildId);
- if (directChildren.Length == 0)
- {
- break;
- }
-
- var childFolders = context.BaseItems
- .WhereOneOrMany(directChildren, e => e.Id)
- .Where(e => e.IsFolder)
+ var linkedFolders = context.BaseItems
+ .Where(e => e.IsFolder && linkedIds.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet();
- foreach (var childId in directChildren)
- {
- if (visited.Add(childId) && childFolders.Contains(childId))
- {
- folderStack.Add(childId);
- }
- }
- }
-
- return visited;
- }
-
- ///
- /// Traverses UP the hierarchy from items to find all ancestor folders.
- ///
- private static HashSet TraverseHierarchyUp(JellyfinDbContext context, ICollection startIds)
- {
- var ancestors = new HashSet();
- var itemStack = new HashSet(startIds);
-
- while (itemStack.Count != 0)
- {
- var currentItems = itemStack.ToArray();
- itemStack.Clear();
-
- var ancestorParents = context.AncestorIds
- .WhereOneOrMany(currentItems, e => e.ItemId)
- .Select(e => e.ParentItemId)
- .ToArray();
-
- var linkedParents = context.LinkedChildren
- .WhereOneOrMany(currentItems, e => e.ChildId)
+ // Folders whose own links have to be followed. Driven off LinkedChildren because owning a
+ // link is the rare property, so the folder check only reaches rows that can qualify. That
+ // check stays: a non-folder owns links too (a movie and its alternate versions).
+ var linkOwners = context.LinkedChildren
+ .Where(e => (closureIds.Contains(e.ParentId) || linkedIds.Contains(e.ParentId))
+ && e.Parent!.IsFolder)
.Select(e => e.ParentId)
+ .Distinct()
.ToArray();
- foreach (var parentId in ancestorParents.Concat(linkedParents))
+ frontier = [];
+ foreach (var id in linkOwners.Concat(linkedFolders))
{
- if (ancestors.Add(parentId))
+ if (!visited.Add(id))
{
- itemStack.Add(parentId);
+ continue;
+ }
+
+ frontier.Add(id);
+ linkRoots.Add(id);
+
+ // Only a folder reached through a link adds a closure the roots so far do not cover.
+ if (linkedFolders.Contains(id))
+ {
+ closureRoots.Add(id);
}
}
}
- return ancestors;
+ return (closureRoots, linkRoots);
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
index 84b86574cc..eae02dda1c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
@@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities
///
/// Gets or sets the id of the associated user.
///
- public Guid? UserId { get; set; }
+ public Guid UserId { get; set; }
///
/// Gets the type of this permission.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
index c02ea7375a..9bd159f2cf 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
@@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities
///
/// Gets or sets the id of the associated user.
///
- public Guid? UserId { get; set; }
+ public Guid UserId { get; set; }
///
/// Gets the type of this preference.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
index b10e210e5d..bf6568d10a 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Interfaces;
@@ -326,7 +325,6 @@ namespace Jellyfin.Database.Implementations.Entities
///
/// Gets the list of permissions this user has.
///
- [ForeignKey("Permission_Permissions_Guid")]
public virtual ICollection Permissions { get; private set; }
/*
@@ -339,7 +337,6 @@ namespace Jellyfin.Database.Implementations.Entities
///
/// Gets the list of preferences this user has.
///
- [ForeignKey("Preference_Preferences_Guid")]
public virtual ICollection Preferences { get; private set; }
///
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
index afa9eee363..48c537bbd3 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
@@ -13,5 +13,9 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration builder)
{
builder.HasKey(e => new { e.ItemId, e.StreamIndex });
+
+ // Covering index for the stream filters. ItemId comes second because it is what they project and
+ // dedupe on; Language and IsExternal follow only to keep their predicates off the table.
+ builder.HasIndex(e => new { e.StreamType, e.ItemId, e.Language, e.IsExternal });
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
index d2aed54eb1..ae53a36724 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
@@ -14,10 +14,8 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
// Used to get a user's permissions or a specific permission for a user.
// Also prevents multiple values being created for a user.
- // Filtered over non-null user ids for when other entities (groups, API keys) get permissions
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
index 207051bcd1..5306078ed4 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
@@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs
new file mode 100644
index 0000000000..afa6840a97
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs
@@ -0,0 +1,1813 @@
+//
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Database.Providers.Sqlite.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20260812050902_AddMediaStreamFilterIndex")]
+ partial class AddMediaStreamFilterIndex
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Album")
+ .HasColumnType("TEXT");
+
+ b.Property("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property("Data")
+ .HasColumnType("TEXT");
+
+ b.Property("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("Path")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("PrimaryVersionId")
+ .HasFilter("\"PrimaryVersionId\" IS NOT NULL");
+
+ b.HasIndex("SeasonId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("SeriesName");
+
+ b.HasIndex("ExtraType", "OwnerId");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "CleanName");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem")
+ .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "SortName");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detached from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId", "ImageType");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ItemId", "ProviderValue");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property