Merge branch 'jellyfin:master' into BDMV-pgs

This commit is contained in:
Oggeb1
2026-08-29 14:02:22 +02:00
committed by GitHub
112 changed files with 8614 additions and 861 deletions
+1 -1
View File
@@ -82,7 +82,7 @@
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
<PackageVersion Include="TMDbLib" Version="3.0.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="UTF.Unknown" Version="2.7.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="Xunit.v3.Priority" Version="1.1.18" />
@@ -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);
@@ -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)
@@ -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<BaseItem> 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);
}
/// <inheritdoc/>
public int DeleteOrphanedCredits()
{
return _peopleRepository.DeleteOrphanedCredits();
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> 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)
@@ -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);
}
@@ -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);
@@ -49,6 +49,14 @@ public class PeopleValidator
/// <returns>Task.</returns>
public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
{
// Before the refresh below walks them: a credit no item maps to any more stands for nothing,
// and while it is there the person it names cannot reach the dead-person sweep either.
var numOrphaned = _libraryManager.DeleteOrphanedCredits();
if (numOrphaned > 0)
{
_logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
}
var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
var numComplete = 0;
@@ -115,6 +123,6 @@ public class PeopleValidator
progress.Report(100);
_logger.LogInformation("People validation complete");
_logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned);
}
}
@@ -106,5 +106,11 @@
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
"CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.",
"LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}",
"NameExtraDeletedScene": "Выдаленая сцэна",
"NameExtraInterview": "Інтэрв'ю",
"NameExtraNumbered": "{0} {1}",
"NameExtraScene": "Сцэна",
"NameExtraTrailer": "Трэйлер"
}
@@ -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": "Оригинал"
}
@@ -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}"
}
@@ -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)"
}
@@ -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 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।"
}
@@ -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.",
@@ -120,5 +120,6 @@
"NameExtraThemeVideo": "Vídeo de Abertura",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Extra",
"NameExtraFeaturette": "Nos Bastidores"
"NameExtraFeaturette": "Nos Bastidores",
"NameExtraInterview": "Entrevista"
}
@@ -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ă"
}
@@ -116,5 +116,10 @@
"NameExtraScene": "Scen",
"NameExtraShort": "Kortfilm",
"NameExtraThemeSong": "Signaturmelodi",
"NameExtraTrailer": "Trailer"
"NameExtraTrailer": "Trailer",
"NameExtraClip": "Klipp",
"NameExtraFeaturette": "Kortfilm",
"NameExtraSample": "Prov",
"NameExtraThemeVideo": "Signaturvideo",
"NameExtraUnknown": "Extra"
}
@@ -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);
@@ -177,33 +177,14 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
List<Guid> 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<bool> 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);
@@ -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();
}
/// <summary>
@@ -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))
@@ -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)
@@ -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
/// <returns>An <see cref="OkResult"/> containing the item.</returns>
[HttpGet("Items/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<BaseItemDto> GetItem(
public async Task<ActionResult<BaseItemDto>> 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<BaseItemDto> GetItemLegacy(
public Task<ActionResult<BaseItemDto>> 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);
}
}
/// <summary>
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using Jellyfin.Server.Implementations.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
@@ -323,10 +324,21 @@ public sealed partial class BaseItemRepository
orderedQuery = query.OrderBy(relevanceExpression);
}
// Folders carry no played flag of their own, so these two keys go through the same predicate
// the isPlayed filter uses rather than through the stored-column lookup in OrderMapper.
Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy) => sortBy switch
{
ItemSortBy.IsPlayed when filter.User is not null
=> AsOrderKey(BuildIsPlayedFilter(context, filter.User)),
ItemSortBy.IsUnplayed when filter.User is not null
=> AsOrderKey(BuildIsPlayedFilter(context, filter.User).Not()),
_ => OrderMapper.MapOrderByField(sortBy, filter, context)
};
if (orderBy.Length > 0)
{
var firstOrdering = orderBy[0];
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
var expression = MapOrderByField(firstOrdering.OrderBy);
if (orderedQuery is null)
{
@@ -350,7 +362,7 @@ public sealed partial class BaseItemRepository
foreach (var item in orderBy.Skip(1))
{
expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
expression = MapOrderByField(item.OrderBy);
orderedQuery = item.SortOrder == SortOrder.Ascending
? orderedQuery.ThenBy(expression)
: orderedQuery.ThenByDescending(expression);
@@ -666,6 +678,9 @@ public sealed partial class BaseItemRepository
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
}
private static Expression<Func<BaseItemEntity, object?>> AsOrderKey(Expression<Func<BaseItemEntity, bool>> predicate)
=> Expression.Lambda<Func<BaseItemEntity, object?>>(Expression.Convert(predicate.Body, typeof(object)), predicate.Parameters);
/// <inheritdoc />
public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
{
@@ -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()!;
}
/// <summary>
/// Gets the latest movies, deduplicated so each movie only appears once.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="baseQuery">The query to pull movies from, with filters already applied.</param>
/// <param name="filter">The original query filter, used when loading the final items.</param>
/// <param name="limit">How many items to return.</param>
/// <returns>The latest movies, newest first.</returns>
private IReadOnlyList<BaseItemDto> GetLatestMovieItems(
JellyfinDbContext context,
IQueryable<BaseItemEntity> 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);
}
/// <summary>
/// Gets the latest TV show items with smart Season/Series container selection.
/// </summary>
@@ -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<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
// Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree.
private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user)
{
var userId = user.Id;
// Leaf items carry their own played state.
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
// Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
// descendant is left unplayed, matching what the DTO reports for them. This has to key off
// the item itself rather than off the requested item types: tag and collection listings mix
// folders and leaf items in a single query.
var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
}
// "und" is the language filters' stand-in for a track that declares no language at all.
private static string NormalizeLanguage(string language)
=> string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language;
// 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<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound)
=> context.BaseItems
.Where(v => v.PrimaryVersionId != null)
.Where(bound)
.Select(v => v.PrimaryVersionId!.Value);
/// <inheritdoc />
public IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> 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)));
}
}
@@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
}
/// <inheritdoc/>
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
ArgumentNullException.ThrowIfNull(filter);
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count();
}
@@ -283,10 +285,23 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
{
var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();
var baseQuery = dbContext.BaseItems
.AsNoTracking()
.WhereOneOrMany(descendantIds, b => b.Id)
.Where(DescendantQueryHelper.IsCountableLeaf);
return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
}
/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
@@ -294,9 +309,9 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
var baseQuery = dbContext.BaseItems
.Where(b => allDescendantIds.Contains(b.Id))
.WhereOneOrMany(allDescendantIds, b => b.Id)
.Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
@@ -330,9 +345,17 @@ public class ItemCountService : IItemCountService
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
{
if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
{
result[parentId] = mergedCount;
continue;
}
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
@@ -342,6 +365,50 @@ public class ItemCountService : IItemCountService
return result;
}
private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
{
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
.Where(group => group.Value.Count > 1)
.ToArray();
if (mergedGroups.Length == 0)
{
return [];
}
// Only merged folders.
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
var children = dbContext.BaseItems
.AsNoTracking()
.Where(b => b.ParentId.HasValue)
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
.ToArray()
.GroupBy(b => b.ParentId)
.ToDictionary(
g => g.Key,
g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
? b.Id.ToString("N", CultureInfo.InvariantCulture)
: b.PresentationUniqueKey).ToArray());
var result = new Dictionary<Guid, int>();
foreach (var (parentId, members) in mergedGroups)
{
var childKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var member in members)
{
if (children.TryGetValue(member, out var keys))
{
childKeys.UnionWith(keys);
}
}
result[parentId] = childKeys.Count;
}
return result;
}
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
@@ -354,10 +421,13 @@ public class ItemCountService : IItemCountService
}
using var dbContext = _dbProvider.CreateDbContext();
var folderIdsArray = folderIds.ToArray();
var filter = new InternalItemsQuery(user);
var userId = user.Id;
// Merged series and seasons are stored as one row per folder-item sharing a presentation key.
var groups = GetPresentationKeyGroups(dbContext, folderIds);
var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();
var leafItems = dbContext.BaseItems
.Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
@@ -399,7 +469,7 @@ public class ItemCountService : IItemCountService
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
var results = ancestorLeaves
var countsByFolder = ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
@@ -411,9 +481,73 @@ public class ItemCountService : IItemCountService
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
var results = new Dictionary<Guid, (int Played, int Total)>();
foreach (var (folderId, members) in groups)
{
var played = 0;
var total = 0;
// Members of a group are distinct folders, so their leaves cannot overlap.
foreach (var member in members)
{
if (countsByFolder.TryGetValue(member, out var counts))
{
played += counts.Played;
total += counts.Total;
}
}
if (total > 0 || played > 0)
{
results[folderId] = (played, total);
}
}
return results;
}
private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds)
{
var requested = dbContext.BaseItems
.AsNoTracking()
.WhereOneOrMany(folderIds, e => e.Id)
.Select(e => new { e.Id, e.PresentationUniqueKey })
.ToArray();
var keys = requested
.Select(e => e.PresentationUniqueKey)
.Where(key => !string.IsNullOrEmpty(key))
.Distinct(StringComparer.Ordinal)
.ToArray();
// Every item that is not merged carries a key derived from its own id, so in the common case
// each group resolves back to the single folder that was asked for.
var membersByKey = keys.Length == 0
? []
: dbContext.BaseItems
.AsNoTracking()
.Where(e => e.IsFolder)
.WhereOneOrMany(keys, e => e.PresentationUniqueKey!)
.Select(e => new { e.Id, Key = e.PresentationUniqueKey! })
.ToArray()
.GroupBy(e => e.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);
var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
var groups = new Dictionary<Guid, List<Guid>>();
foreach (var folderId in folderIds)
{
groups[folderId] = keyById.TryGetValue(folderId, out var key)
&& !string.IsNullOrEmpty(key)
&& membersByKey.TryGetValue(key, out var members)
&& members.Count > 0
? members
: [folderId];
}
return groups;
}
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
@@ -194,12 +194,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
listOrder++;
}
var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray();
context.PeopleBaseItemMap.RemoveRange(existingMaps);
context.SaveChanges();
// Nothing else ever deletes a credit row, so one left without a single mapping outlives the
// credit it stood for: it keeps a person of that name off the dead-person sweep, which only
// sees items no credit names, and keeps the name in every by-name list. That is how a credit
// a provider dropped, or one a broken provider result invented, becomes impossible to clean up.
DeleteCreditsWithoutMapping(context, droppedCredits);
context.SaveChanges();
transaction.Commit();
}
/// <inheritdoc/>
public int DeleteOrphanedCredits()
{
using var context = _dbProvider.CreateDbContext();
return DeleteCreditsWithoutMapping(context, null);
}
// A null candidate list sweeps every credit, anything else only the ones just unmapped.
private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList<Guid>? candidates)
{
if (candidates is not null && candidates.Count == 0)
{
return 0;
}
var credits = candidates is null
? context.Peoples.AsQueryable()
: context.Peoples.WhereOneOrMany(candidates, e => e.Id);
return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete();
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
@@ -351,7 +383,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> 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<JellyfinDbContext> 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();
@@ -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<Permission> source)
{
var incoming = new Dictionary<PermissionKind, bool>();
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<Preference> source)
{
var incoming = new Dictionary<PreferenceKind, string>();
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<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
{
// TODO: Remove after user item data is migrated.
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -15,9 +17,9 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library.
/// </summary>
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
@@ -53,6 +55,7 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
const int ProgressInterval = 250;
var sw = Stopwatch.StartNew();
var newSeriesKeys = new Dictionary<Guid, string>();
var processed = 0;
var updated = 0;
@@ -68,9 +71,10 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
}
var oldKey = item.PresentationUniqueKey;
var newKey = item.CreatePresentationUniqueKey();
if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
newSeriesKeys[item.Id] = newKey;
if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}
@@ -82,21 +86,66 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
// Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
// to the series by it. Re-point every child still carrying the old key in a single set-based
// update so they stay attached without waiting for the next scan.
if (!string.IsNullOrEmpty(oldKey))
{
await dbContext.BaseItems
.Where(e => e.SeriesPresentationUniqueKey == oldKey)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
}
// Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so
// re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than
// by the old key: that key can be shared by every library holding the series, so matching
// on it would drag the other libraries' children along.
await dbContext.BaseItems
.Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id))
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
updated++;
}
var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}",
updated,
series.Length,
updatedSeasons,
sw.Elapsed);
}
}
private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, CancellationToken cancellationToken)
{
// A season's own key embeds its series' key, so it goes stale with it.
var seasons = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Season]
}).OfType<Season>().ToArray();
var updated = 0;
foreach (var season in seasons)
{
cancellationToken.ThrowIfCancellationRequested();
// Without an index number the season keeps the base key, which carries no series key at all.
if (!season.IndexNumber.HasValue
|| !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey))
{
continue;
}
// Mirrors Season.CreatePresentationUniqueKey.
var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture);
if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}
var id = season.Id;
await dbContext.BaseItems
.Where(e => e.Id.Equals(id))
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
updated++;
}
_logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
return updated;
}
}
@@ -0,0 +1,334 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Moves the views whose id used to be derived from their localized name onto their name independent id.
/// </summary>
[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
{
private readonly IStartupLogger<ConsolidateLocalizedUserViews> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ConsolidateLocalizedUserViews"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="configurationManager">The server configuration manager.</param>
/// <param name="fileSystem">The file system.</param>
/// <param name="dbProvider">The database context factory.</param>
public ConsolidateLocalizedUserViews(
IStartupLogger<ConsolidateLocalizedUserViews> logger,
ILibraryManager libraryManager,
IServerConfigurationManager configurationManager,
IFileSystem fileSystem,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
_logger = logger;
_libraryManager = libraryManager;
_configurationManager = configurationManager;
_fileSystem = fileSystem;
_dbProvider = dbProvider;
}
/// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken)
{
// The Live TV view is the one that hurts: every channel and program is parented to it, so a
// translation update or a change of UI culture used to leave them behind under a view nothing
// looks up any more.
var views = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.UserView]
}).OfType<UserView>().Where(view => view.ViewType.HasValue).ToArray();
if (views.Length == 0)
{
return;
}
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
foreach (var group in views.GroupBy(view => view.ViewType!.Value))
{
cancellationToken.ThrowIfCancellationRequested();
var viewType = group.Key;
var folderName = _fileSystem.GetValidFilename(viewType.ToString());
var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName);
// Only the views created for a view type as a whole are named after it. The per user and
// per parent ones get a folder of their own, and carry no children to lose. Match on the
// folder rather than the whole path so a metadata directory that has since moved still
// lines up.
var candidates = group
.Where(view => !string.IsNullOrEmpty(view.Path)
&& string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (candidates.Length == 0)
{
continue;
}
// Mirrors LibraryManager.GetNamedView(name, viewType, sortName).
var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView));
var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray();
if (stale.Length == 0)
{
continue;
}
await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task ConsolidateAsync(
JellyfinDbContext dbContext,
CollectionType viewType,
string path,
Guid canonicalId,
IReadOnlyList<UserView> candidates,
IReadOnlyList<UserView> stale,
CancellationToken cancellationToken)
{
var staleIds = stale.Select(view => view.Id).ToArray();
Guid? newParentId = canonicalId;
var sourceId = Guid.Empty;
if (!candidates.Any(view => view.Id.Equals(canonicalId)))
{
// Whichever of the old views the items ended up under is the one worth keeping, so give the
// canonical id a copy of it.
var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false);
sourceId = source.Id;
_libraryManager.CreateItem(
new UserView
{
Path = path,
Id = canonicalId,
DateCreated = source.DateCreated,
DateModified = source.DateModified,
Name = source.Name,
ViewType = viewType,
ForcedSortName = source.ForcedSortName
},
null);
}
var reparented = await dbContext.BaseItems
.Where(e => e.ParentId.HasValue)
.WhereOneOrMany(staleIds, e => e.ParentId!.Value)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken)
.ConfigureAwait(false);
await dbContext.BaseItems
.Where(e => e.TopParentId.HasValue)
.WhereOneOrMany(staleIds, e => e.TopParentId!.Value)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken)
.ConfigureAwait(false);
await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false);
await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false);
// Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last.
await dbContext.BaseItems
.WhereOneOrMany(staleIds, e => e.Id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}",
reparented,
staleIds.Length,
viewType,
canonicalId);
}
private async Task<UserView> PickSourceAsync(
JellyfinDbContext dbContext,
IReadOnlyList<UserView> stale,
IReadOnlyList<Guid> staleIds,
CancellationToken cancellationToken)
{
var childCounts = await dbContext.BaseItems
.Where(e => e.ParentId.HasValue)
.WhereOneOrMany(staleIds, e => e.ParentId!.Value)
.GroupBy(e => e.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken)
.ConfigureAwait(false);
return stale
.OrderByDescending(view => childCounts.GetValueOrDefault(view.Id))
.ThenBy(view => view.DateCreated)
.First();
}
private static async Task MoveUserSettingsAsync(
JellyfinDbContext dbContext,
Guid canonicalId,
Guid sourceId,
IReadOnlyList<Guid> staleIds,
CancellationToken cancellationToken)
{
// Everything below is keyed by the view's id, and a view holding no children still holds the
// ordering it was given and whether it was hidden. Only the view that was promoted can hand
// those over - the rest would collide on the one row per user, item and client - so the others
// are dropped instead.
var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray();
if (!sourceId.Equals(Guid.Empty))
{
var moved = new[] { sourceId };
await dbContext.DisplayPreferences
.WhereOneOrMany(moved, e => e.ItemId)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
.ConfigureAwait(false);
await dbContext.ItemDisplayPreferences
.WhereOneOrMany(moved, e => e.ItemId)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
.ConfigureAwait(false);
await dbContext.CustomItemDisplayPreferences
.WhereOneOrMany(moved, e => e.ItemId)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
.ConfigureAwait(false);
}
if (dropped.Length > 0)
{
await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
var stale = staleIds.ToHashSet();
var preferences = await dbContext.Preferences
.Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var changed = false;
foreach (var preference in preferences)
{
var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var rewritten = new List<string>(values.Length);
var seen = new HashSet<Guid>();
var touched = false;
foreach (var value in values)
{
// Clients write these in both the dashed and the plain form, so compare them parsed.
if (!Guid.TryParse(value, out var parsed))
{
rewritten.Add(value);
continue;
}
var isStale = stale.Contains(parsed);
if (isStale)
{
parsed = canonicalId;
touched = true;
}
// The same view can be listed twice once both of its ids point at the same place.
if (!seen.Add(parsed))
{
continue;
}
rewritten.Add(isStale
? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCulture)
: value);
}
if (!touched)
{
continue;
}
preference.Value = string.Join(',', rewritten);
changed = true;
}
if (changed)
{
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
private static async Task MoveAncestorsAsync(
JellyfinDbContext dbContext,
Guid canonicalId,
IReadOnlyList<Guid> staleIds,
CancellationToken cancellationToken)
{
var items = await dbContext.AncestorIds
.WhereOneOrMany(staleIds, e => e.ParentItemId)
.Select(e => e.ItemId)
.Distinct()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
await dbContext.AncestorIds
.WhereOneOrMany(staleIds, e => e.ParentItemId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
if (items.Count == 0)
{
return;
}
// The pair is the primary key, so anything already recorded against the canonical view stays put.
var existing = await dbContext.AncestorIds
.Where(e => e.ParentItemId.Equals(canonicalId))
.Select(e => e.ItemId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var itemId in items.Except(existing))
{
dbContext.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = canonicalId,
Item = null!,
ParentItem = null!
});
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -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);
}
@@ -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);
}
@@ -48,6 +48,10 @@ namespace MediaBrowser.Controller.Entities
public const string ThemeSongFileName = "theme";
// Well below the 255 byte limit of the common Linux filesystems and the 255 character limit
// of Windows, so the files inside the folder still fit within MAX_PATH.
private const int MaxItemByNameFolderNameBytes = 128;
/// <summary>
/// The supported image extensions.
/// </summary>
@@ -941,6 +945,43 @@ namespace MediaBrowser.Controller.Entities
return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
/// <summary>
/// Turns an item-by-name entity's name into a folder name every supported filesystem accepts.
/// </summary>
/// <param name="name">The entity's name.</param>
/// <returns>The folder name.</returns>
public static string GetItemByNameFolderName(string name)
{
// Trim the period at the end because windows will have a hard time with that
var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.');
// Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be
// turned into a folder at all - and an entity with no folder can never be created, which
// leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan.
// Only broken provider data gets this long, but it still has to resolve to something, so
// keep a readable prefix and let a hash of the whole name tell two of them apart.
if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes)
{
return validName;
}
var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture);
var budget = MaxItemByNameFolderNameBytes - suffix.Length;
var length = Math.Min(validName.Length, budget);
while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget)
{
length--;
}
// Never cut a surrogate pair in half, the lone half is not a valid file name character.
if (length > 0 && char.IsHighSurrogate(validName[length - 1]))
{
length--;
}
return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix);
}
/// <summary>
/// Cleans a raw name into its sortable form by applying the configured sort rules.
/// </summary>
+1 -4
View File
@@ -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);
}
+1 -4
View File
@@ -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;
+1 -4
View File
@@ -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);
}
+16 -7
View File
@@ -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)
+1 -4
View File
@@ -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);
}
@@ -605,6 +605,12 @@ namespace MediaBrowser.Controller.Library
/// <returns>List&lt;System.String&gt;.</returns>
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query);
/// <summary>
/// Deletes every credit that no item maps to any more.
/// </summary>
/// <returns>The number of credits that were deleted.</returns>
int DeleteOrphanedCredits();
/// <summary>
/// Gets the distinct people names per item for multiple items.
/// </summary>
@@ -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.
@@ -33,6 +33,12 @@ public interface IPeopleRepository
/// <returns>The list of people names matching the filter.</returns>
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter);
/// <summary>
/// Deletes every credit that no item maps to any more.
/// </summary>
/// <returns>The number of credits that were deleted.</returns>
int DeleteOrphanedCredits();
/// <summary>
/// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table.
/// </summary>
@@ -16,11 +16,6 @@ namespace MediaBrowser.Controller.Providers
private List<(string Url, ImageType Type)> _remoteImages;
private List<PersonInfo> _people;
public MetadataResult()
{
ResultLanguage = "en";
}
public List<LocalImageInfo> Images
{
get => _images ??= [];
@@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers
public T Item { get; set; }
/// <summary>
/// Gets or sets the language the fetched metadata is in.
/// </summary>
public string ResultLanguage { get; set; }
public string Provider { get; set; }
@@ -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;
@@ -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);
}
/// <summary>
/// Normalizes the path.
/// </summary>
/// <param name="path">The path.</param>
/// <returns>System.String.</returns>
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);
}
}
}
@@ -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)
@@ -141,6 +141,7 @@ public class TranscodingProfile
/// Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported.
/// </summary>
[DefaultValue(false)]
[XmlIgnore]
[XmlAttribute("breakOnNonKeyFrames")]
[Obsolete("This is always false")]
public bool? BreakOnNonKeyFrames { get; set; }
@@ -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;
/// <summary>
/// Class ProviderIdsExtensions.
/// </summary>
public static class ProviderIdsExtensions
public static partial class ProviderIdsExtensions
{
/// <summary>
/// Case-insensitive dictionary of <see cref="MetadataProvider"/> string representation.
@@ -20,6 +22,27 @@ public static class ProviderIdsExtensions
enumValue => enumValue.ToString(),
StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The known id formats, keyed by provider name.
/// </summary>
private static readonly Dictionary<string, Func<string, bool>> _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
};
/// <summary>
/// Checks if this instance has an id for the given provider.
/// </summary>
@@ -101,6 +124,26 @@ public static class ProviderIdsExtensions
return instance.GetProviderId(provider.ToString());
}
/// <summary>
/// Checks whether a value can be an id of the given provider.
/// </summary>
/// <param name="name">The provider name.</param>
/// <param name="value">The provider id.</param>
/// <returns><c>true</c> if the value has a plausible format for the provider; otherwise, <c>false</c>.</returns>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>
/// Sets a provider id.
/// </summary>
@@ -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<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -153,7 +204,6 @@ public static class ProviderIdsExtensions
/// <param name="instance">The instance.</param>
/// <param name="name">The name, this should not contain a '=' character.</param>
/// <param name="value">The value.</param>
/// <remarks>Due to how deserialization from the database works the name cannot contain '='.</remarks>
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<string, string>(StringComparer.OrdinalIgnoreCase);
instance.TrySetProviderId(name, value);
}
// Match on internal MetadataProvider enum string values before adding arbitrary providers
if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
/// <summary>
/// Replaces all provider ids, dropping the ones that cannot belong to the provider they are filed under.
/// </summary>
/// <param name="instance">The instance.</param>
/// <param name="providerIds">The provider ids to set.</param>
public static void SetProviderIds(this IHasProviderIds instance, IReadOnlyDictionary<string, string>? providerIds)
{
ArgumentNullException.ThrowIfNull(instance);
instance.ProviderIds = new Dictionary<string, string>(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();
}
@@ -204,7 +204,7 @@ public class ComicBookInfoProvider : IComicProvider
{
try
{
return CultureInfo.GetCultureInfo(language).DisplayName;
return CultureInfo.GetCultureInfo(language).TwoLetterISOLanguageName;
}
catch (CultureNotFoundException)
{
@@ -52,7 +52,7 @@ public class ExternalComicInfoProvider : IComicProvider
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName);
return metadataResult;
}
@@ -50,7 +50,7 @@ public class InternalComicInfoProvider : IComicProvider
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName);
return metadataResult;
}
@@ -0,0 +1,44 @@
using System;
namespace MediaBrowser.Providers.Manager;
/// <summary>
/// Helpers for comparing the language of fetched metadata with the language that was requested.
/// </summary>
internal static class MetadataLanguageUtils
{
/// <summary>
/// Gets the language subtag of a language tag, e.g. "es" for "es-ES".
/// </summary>
/// <param name="language">The language tag.</param>
/// <returns>The language subtag, lowercased, or <c>null</c> if none was given.</returns>
public static string? GetLanguageSubtag(string? language)
{
if (string.IsNullOrEmpty(language))
{
return null;
}
var separator = language.IndexOf('-', StringComparison.Ordinal);
return (separator == -1 ? language : language[..separator]).ToLowerInvariant();
}
/// <summary>
/// Determines whether a provider result can be considered to be in the requested language.
/// </summary>
/// <param name="resultLanguage">The language the provider reported for its result, if any.</param>
/// <param name="preferredLanguage">The language that was requested, if any.</param>
/// <returns><c>true</c> if the result is in the requested language or either language is unknown.</returns>
public static bool MatchesPreferredLanguage(string? resultLanguage, string? preferredLanguage)
{
// A provider that doesn't report a language cannot be judged, assume it honored the request
if (string.IsNullOrEmpty(resultLanguage) || string.IsNullOrEmpty(preferredLanguage))
{
return true;
}
// Compare on the language subtag only so that e.g. "es" matches "es-ES"
return string.Equals(GetLanguageSubtag(resultLanguage), GetLanguageSubtag(preferredLanguage), StringComparison.Ordinal);
}
}
+186 -16
View File
@@ -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<ItemUpdateType> SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, MetadataResult<TItemType> metadataResult, CancellationToken cancellationToken)
async Task<ItemUpdateType> SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, bool refreshStampNeedsSaving, MetadataResult<TItemType> 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<string, string> GetValidProviderIds(IReadOnlyDictionary<string, string> providerIds)
{
var validProviderIds = new Dictionary<string, string>(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<TItemType> 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<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, bool replaceData, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken)
{
var refreshResult = new RefreshResult();
var preferredLanguage = id?.MetadataLanguage;
var overviewIsFallback = false;
var taglineIsFallback = false;
if (id is not null)
{
@@ -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;
}
/// <summary>
/// Reports the ids a provider returned that cannot belong to the provider they are filed under.
/// </summary>
/// <remarks>
/// The ids are dropped when merging, this names the provider that produced them so the source of a
/// recurring bad id can be found.
/// </remarks>
private void LogInvalidProviderIds(MetadataResult<TItemType> 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<string, string> 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<PersonInfo> 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<PersonInfo> source, IReadOnlyList<PersonInfo> target)
{
var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase);
@@ -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;
}
/// <summary>
/// Returns the id if it can be an id of the given provider, otherwise <c>null</c>.
/// </summary>
private static string? MusicBrainzId(MetadataProvider provider, string? id)
=> ProviderIdsExtensions.IsValidProviderId(provider.ToString(), id) ? id : null;
}
}
@@ -21,6 +21,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Music;
namespace MediaBrowser.Providers.Plugins.AudioDb
@@ -77,7 +78,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
{
result.Item = new MusicAlbum();
result.HasMetadata = true;
ProcessResult(result.Item, obj.album[0], info.MetadataLanguage);
ProcessResult(result, obj.album[0], info.MetadataLanguage);
}
}
}
@@ -85,8 +86,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
return result;
}
private void ProcessResult(MusicAlbum item, Album result, string preferredLanguage)
private void ProcessResult(MetadataResult<MusicAlbum> metadataResult, Album result, string preferredLanguage)
{
var item = metadataResult.Item;
if (Plugin.Instance.Configuration.ReplaceAlbumName && !string.IsNullOrWhiteSpace(result.strAlbum))
{
item.Album = result.strAlbum;
@@ -113,43 +116,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
item.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, result.strMusicBrainzArtistID);
item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, result.strMusicBrainzID);
string overview = null;
if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionDE;
}
else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionFR;
}
else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionNL;
}
else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionRU;
}
else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionIT;
}
else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionPT;
}
var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage);
var overview = GetDescription(result, language);
if (string.IsNullOrWhiteSpace(overview))
{
overview = string.IsNullOrWhiteSpace(result.strDescriptionEN)
? result.strDescription
: result.strDescriptionEN;
// The description is not in the requested language, mark it as English so it does not
// block a provider further down the list that can serve the requested language
metadataResult.ResultLanguage = "en";
}
else
{
metadataResult.ResultLanguage = language;
}
item.Overview = (overview ?? string.Empty).StripHtml();
}
private static string GetDescription(Album result, string language)
=> language switch
{
"de" => result.strDescriptionDE,
"en" => result.strDescriptionEN,
"es" => result.strDescriptionES,
"fr" => result.strDescriptionFR,
"he" => result.strDescriptionIL,
"hu" => result.strDescriptionHU,
"it" => result.strDescriptionIT,
"ja" => result.strDescriptionJP,
"nl" => result.strDescriptionNL,
"no" or "nb" or "nn" => result.strDescriptionNO,
"pl" => result.strDescriptionPL,
"pt" => result.strDescriptionPT,
"ru" => result.strDescriptionRU,
"sv" => result.strDescriptionSE,
"zh" => result.strDescriptionCN,
_ => null
};
internal async Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken)
{
var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId);
@@ -22,6 +22,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Music;
namespace MediaBrowser.Providers.Plugins.AudioDb
@@ -148,7 +149,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
{
result.Item = new MusicArtist();
result.HasMetadata = true;
ProcessResult(result.Item, artist, info.MetadataLanguage);
ProcessResult(result, artist, info.MetadataLanguage);
}
return result;
@@ -193,8 +194,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
return null;
}
private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage)
private void ProcessResult(MetadataResult<MusicArtist> metadataResult, Artist result, string preferredLanguage)
{
var item = metadataResult.Item;
if (!string.IsNullOrWhiteSpace(result.strWebsite))
{
item.HomePageUrl = result.strWebsite;
@@ -229,43 +232,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist);
item.SetProviderId(MetadataProvider.MusicBrainzArtist, result.strMusicBrainzID);
string overview = null;
if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyDE;
}
else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyFR;
}
else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyNL;
}
else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyRU;
}
else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyIT;
}
else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyPT;
}
var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage);
var overview = GetBiography(result, language);
if (string.IsNullOrWhiteSpace(overview))
{
overview = string.IsNullOrWhiteSpace(result.strBiographyEN)
? result.strBiography
: result.strBiographyEN;
// The biography is not in the requested language, mark it as English so it does not
// block a provider further down the list that can serve the requested language
metadataResult.ResultLanguage = "en";
}
else
{
metadataResult.ResultLanguage = language;
}
item.Overview = (overview ?? string.Empty).StripHtml();
}
private static string GetBiography(Artist result, string language)
=> language switch
{
"de" => result.strBiographyDE,
"en" => result.strBiographyEN,
"es" => result.strBiographyES,
"fr" => result.strBiographyFR,
"he" => result.strBiographyIL,
"hu" => result.strBiographyHU,
"it" => result.strBiographyIT,
"ja" => result.strBiographyJP,
"nl" => result.strBiographyNL,
"no" or "nb" or "nn" => result.strBiographyNO,
"pl" => result.strBiographyPL,
"pt" => result.strBiographyPT,
"ru" => result.strBiographyRU,
"sv" => result.strBiographySE,
"zh" => result.strBiographyCN,
_ => null
};
internal async Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken)
{
var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
@@ -44,7 +44,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var result = new MetadataResult<Episode>
{
Item = new Episode(),
QueriedById = true
QueriedById = true,
// OMDb is not localized, everything it returns is English
ResultLanguage = "en"
};
// Allowing this will dramatically increase scan times
@@ -218,7 +218,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var result = new MetadataResult<T>
{
Item = new T(),
QueriedById = true
QueriedById = true,
// OMDb is not localized, everything it returns is English
ResultLanguage = "en"
};
var imdbId = info.GetProviderId(MetadataProvider.Imdb);
@@ -27,6 +27,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
/// <summary>Provider for OMDB service.</summary>
public class OmdbProvider
{
/// <summary>Generational suffixes that OMDb separates from the name with a comma.</summary>
private static readonly string[] NameSuffixes = ["Jr", "Jnr", "Sr", "Snr", "II", "III", "IV", "V"];
private readonly IFileSystem _fileSystem;
private readonly IServerConfigurationManager _configurationManager;
private readonly IHttpClientFactory _httpClientFactory;
@@ -420,42 +423,96 @@ namespace MediaBrowser.Providers.Plugins.Omdb
return;
}
if (!string.IsNullOrWhiteSpace(result.Director))
{
var person = new PersonInfo
{
Name = result.Director.Trim(),
Type = PersonKind.Director
};
AddPeople(itemResult, result.Director, PersonKind.Director);
AddPeople(itemResult, result.Writer, PersonKind.Writer);
AddPeople(itemResult, result.Actors, PersonKind.Actor);
}
itemResult.AddPerson(person);
/// <summary>Adds the people from a comma separated OMDb credit list.</summary>
/// <typeparam name="T">The item type.</typeparam>
/// <param name="itemResult">The metadata result to add the people to.</param>
/// <param name="credits">The comma separated OMDb credit list.</param>
/// <param name="type">The kind of person each credit describes.</param>
internal static void AddPeople<T>(MetadataResult<T> itemResult, string credits, PersonKind type)
where T : BaseItem
{
if (string.IsNullOrWhiteSpace(credits))
{
return;
}
if (!string.IsNullOrWhiteSpace(result.Writer))
{
var person = new PersonInfo
{
Name = result.Writer.Trim(),
Type = PersonKind.Writer
};
var names = new List<string>();
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<string> 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)
@@ -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
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
item.TryGetTmdbId(out var tmdbId);
if (tmdbId <= 0)
{
@@ -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
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> 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
/// <inheritdoc />
public async Task<MetadataResult<BoxSet>> 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<BoxSet>();
var result = new MetadataResult<BoxSet>
{
ResultLanguage = language
};
if (tmdbId > 0)
{
@@ -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);
@@ -54,11 +54,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> 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<SearchMovie>? 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
/// <inheritdoc />
public async Task<MetadataResult<Movie>> 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<Movie>();
}
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)
{
@@ -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<RemoteImageInfo>();
}
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<RemoteImageInfo>();
@@ -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
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> 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
/// <inheritdoc />
public async Task<MetadataResult<Person>> 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<Person>();
var result = new MetadataResult<Person>
{
ResultLanguage = info.MetadataLanguage
};
if (personTmdbId > 0)
{
@@ -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<RemoteImageInfo>();
}
@@ -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;
}
@@ -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<RemoteImageInfo>();
}
@@ -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
/// <inheritdoc />
public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Season>();
var result = new MetadataResult<Season>
{
ResultLanguage = info.MetadataLanguage
};
var config = Plugin.Instance.Configuration;
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId);
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)
@@ -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
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
if (string.IsNullOrEmpty(tmdbId))
if (!item.TryGetTmdbId(out var tmdbId))
{
return Enumerable.Empty<RemoteImageInfo>();
}
@@ -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)
@@ -54,10 +54,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> 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<Cast> castQuery = seriesResult.Credits.Cast.OrderBy(a => a.Order);
if (config.HideMissingCastMembers)
{
castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath));
}
foreach (var actor in castQuery.Take(config.MaxCastMembers))
{
if (string.IsNullOrWhiteSpace(actor.Name))
{
continue;
}
var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
Role = actor.Character?.Trim() ?? string.Empty,
Type = PersonKind.Actor,
SortOrder = actor.Order,
// NOTE: Null values are filtered out above
ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath!)
};
if (actor.Id > 0)
{
personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture));
}
yield return personInfo;
}
yield return actor;
}
if (seriesResult.Credits?.Crew is not null)
@@ -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;
@@ -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();
/// <summary>
/// Gets the TMDb id of an item, if it has one TMDb can be queried with.
/// </summary>
/// <param name="instance">The item.</param>
/// <param name="tmdbId">The TMDb id.</param>
/// <returns><c>true</c> if the item has a usable TMDb id; otherwise, <c>false</c>.</returns>
public static bool TryGetTmdbId(this IHasProviderIds instance, out int tmdbId)
{
instance.TryGetProviderId(MetadataProvider.Tmdb, out var value);
return TryParseTmdbId(value, out tmdbId);
}
/// <summary>
/// Parses a TMDb id.
/// </summary>
/// <param name="value">The stored id.</param>
/// <param name="tmdbId">The TMDb id.</param>
/// <returns><c>true</c> if the value is a usable TMDb id; otherwise, <c>false</c>.</returns>
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;
}
/// <summary>
/// Cleans the name according to TMDb requirements.
/// </summary>
@@ -101,6 +132,100 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
return PersonKind.Unknown;
}
/// <summary>
/// Maps an aggregated TMDb cast list, whose entries hold every role their member played.
/// </summary>
/// <param name="cast">The aggregated cast list, or <c>null</c>.</param>
/// <param name="config">The configuration deciding how much of the cast to keep.</param>
/// <param name="getProfileUrl">Resolves a profile path into an absolute image url.</param>
/// <returns>One credit per role played.</returns>
internal static IEnumerable<PersonInfo> MapAggregateCast(
IReadOnlyList<CastAggregate>? cast,
PluginConfiguration config,
Func<string?, string?> getProfileUrl)
{
if (cast is null)
{
yield break;
}
var billed = cast
.Where(member => !string.IsNullOrWhiteSpace(member.Name))
.Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath))
.OrderBy(member => member.Order)
.Take(config.MaxCastMembers);
foreach (var member in billed)
{
// An actor playing several characters over the run gets one aggregated entry holding
// every role, so each of them becomes a credit of its own here. Their own billing puts
// the character they played the longest first.
var characters = member.Roles?
.Where(role => !string.IsNullOrWhiteSpace(role.Character))
.OrderByDescending(role => role.EpisodeCount)
.Select(role => role.Character!.Trim())
.ToArray();
if (characters is null || characters.Length == 0)
{
characters = [string.Empty];
}
foreach (var character in characters)
{
yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, character, getProfileUrl);
}
}
}
/// <summary>
/// Maps a TMDb cast list whose entries hold the one character their member is credited for.
/// </summary>
/// <param name="cast">The cast list, or <c>null</c>.</param>
/// <param name="config">The configuration deciding how much of the cast to keep.</param>
/// <param name="getProfileUrl">Resolves a profile path into an absolute image url.</param>
/// <returns>One credit per cast entry.</returns>
internal static IEnumerable<PersonInfo> MapCast(
IReadOnlyList<Cast>? cast,
PluginConfiguration config,
Func<string?, string?> getProfileUrl)
{
if (cast is null)
{
yield break;
}
var billed = cast
.Where(member => !string.IsNullOrWhiteSpace(member.Name))
.Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath))
.OrderBy(member => member.Order)
.Take(config.MaxCastMembers);
foreach (var member in billed)
{
yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, member.Character?.Trim() ?? string.Empty, getProfileUrl);
}
}
private static PersonInfo CreateCredit(string name, int id, string? profilePath, int? order, string role, Func<string?, string?> getProfileUrl)
{
var personInfo = new PersonInfo
{
Name = name.Trim(),
Role = role,
Type = PersonKind.Actor,
SortOrder = order,
ImageUrl = getProfileUrl(profilePath)
};
if (id > 0)
{
personInfo.SetProviderId(MetadataProvider.Tmdb, id.ToString(CultureInfo.InvariantCulture));
}
return personInfo;
}
/// <summary>
/// Determines whether a video is a trailer.
/// </summary>
@@ -364,7 +364,7 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
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<Series, SeriesInfo>
// 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);
}
}
@@ -8,7 +8,7 @@ using Jellyfin.Database.Implementations.MatchCriteria;
namespace Jellyfin.Database.Implementations;
/// <summary>
/// Provides methods for querying item hierarchies using iterative traversal.
/// Provides methods for querying item hierarchies.
/// Uses AncestorIds and LinkedChildren tables for parent-child traversal.
/// </summary>
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);
/// <summary>
/// Gets all descendant IDs for multiple parent items in a single traversal.
/// Traverses AncestorIds and LinkedChildren, like <see cref="GetAllDescendantIds"/>, but resolves
/// the roots once for all seeds instead of once per seed.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="parentIds">Parent item IDs.</param>
/// <returns>Set of all descendant item IDs (excluding the parent IDs themselves).</returns>
public static HashSet<Guid> GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(parentIds);
return descendants.AsQueryable();
if (parentIds.Count == 0)
{
return [];
}
var descendants = AllDescendants(context, parentIds)
.Distinct()
.ToHashSet();
descendants.ExceptWith(parentIds);
return descendants;
}
/// <summary>
@@ -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();
}
/// <summary>
@@ -76,11 +99,11 @@ public static class DescendantQueryHelper
return [];
}
var seedSet = new HashSet<Guid>(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();
}
/// <summary>
/// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the
/// criteria.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="criteria">The matching criteria to apply.</param>
/// <returns>Queryable of primary version item IDs.</returns>
/// <remarks>
/// 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.
/// </remarks>
public static IQueryable<Guid> 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<Guid> 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<MediaStreamInfo> 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<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
private static IQueryable<MediaStreamInfo> 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;
}
/// <summary>
/// Traverses DOWN the hierarchy from parent folders to find all descendants.
/// </summary>
private static HashSet<Guid> TraverseHierarchyDown(JellyfinDbContext context, ICollection<Guid> startIds)
private static IQueryable<Guid> AllDescendants(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
{
var visited = new HashSet<Guid>(startIds);
var folderStack = new HashSet<Guid>(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<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> 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<Guid> ResolveLinkParents(JellyfinDbContext context, IQueryable<Guid> matchingItemIds, IQueryable<Guid> 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];
}
/// <summary>
/// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren.
/// </summary>
private static HashSet<Guid> TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection<Guid> startIds)
// Resolves the roots the descendant sub-selects are anchored on: those contributing their closure,
// and those contributing their linked children.
private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
{
var visited = new HashSet<Guid>(startIds);
var folderStack = new HashSet<Guid>(startIds);
var visited = new HashSet<Guid>(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;
}
/// <summary>
/// Traverses UP the hierarchy from items to find all ancestor folders.
/// </summary>
private static HashSet<Guid> TraverseHierarchyUp(JellyfinDbContext context, ICollection<Guid> startIds)
{
var ancestors = new HashSet<Guid>();
var itemStack = new HashSet<Guid>(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);
}
}
@@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets or sets the id of the associated user.
/// </summary>
public Guid? UserId { get; set; }
public Guid UserId { get; set; }
/// <summary>
/// Gets the type of this permission.
@@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets or sets the id of the associated user.
/// </summary>
public Guid? UserId { get; set; }
public Guid UserId { get; set; }
/// <summary>
/// Gets the type of this preference.
@@ -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
/// <summary>
/// Gets the list of permissions this user has.
/// </summary>
[ForeignKey("Permission_Permissions_Guid")]
public virtual ICollection<Permission> Permissions { get; private set; }
/*
@@ -339,7 +337,6 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets the list of preferences this user has.
/// </summary>
[ForeignKey("Preference_Preferences_Guid")]
public virtual ICollection<Preference> Preferences { get; private set; }
/// <inheritdoc/>
@@ -13,5 +13,9 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration<MediaStream
public void Configure(EntityTypeBuilder<MediaStreamInfo> 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 });
}
}
@@ -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();
}
}
@@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
builder
.HasIndex(p => new { p.UserId, p.Kind })
.HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddMediaStreamFilterIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
table: "MediaStreamInfos",
columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
table: "MediaStreamInfos");
}
}
}
@@ -0,0 +1,120 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class RemoveOrphanedUserPermissionsAndPreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL;");
migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL;");
migrationBuilder.DropIndex(
name: "IX_Preferences_UserId_Kind",
table: "Preferences");
migrationBuilder.DropIndex(
name: "IX_Permissions_UserId_Kind",
table: "Permissions");
migrationBuilder.DropColumn(
name: "Preference_Preferences_Guid",
table: "Preferences");
migrationBuilder.DropColumn(
name: "Permission_Permissions_Guid",
table: "Permissions");
migrationBuilder.AlterColumn<Guid>(
name: "UserId",
table: "Preferences",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "UserId",
table: "Permissions",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Preferences_UserId_Kind",
table: "Preferences",
columns: ["UserId", "Kind"],
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Permissions_UserId_Kind",
table: "Permissions",
columns: ["UserId", "Kind"],
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Preferences_UserId_Kind",
table: "Preferences");
migrationBuilder.DropIndex(
name: "IX_Permissions_UserId_Kind",
table: "Permissions");
migrationBuilder.AlterColumn<Guid>(
name: "UserId",
table: "Preferences",
type: "TEXT",
nullable: true,
oldClrType: typeof(Guid),
oldType: "TEXT");
migrationBuilder.AddColumn<Guid>(
name: "Preference_Preferences_Guid",
table: "Preferences",
type: "TEXT",
nullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "UserId",
table: "Permissions",
type: "TEXT",
nullable: true,
oldClrType: typeof(Guid),
oldType: "TEXT");
migrationBuilder.AddColumn<Guid>(
name: "Permission_Permissions_Guid",
table: "Permissions",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Preferences_UserId_Kind",
table: "Preferences",
columns: ["UserId", "Kind"],
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Permissions_UserId_Kind",
table: "Permissions",
columns: ["UserId", "Kind"],
unique: true,
filter: "[UserId] IS NOT NULL");
}
}
}
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -1012,6 +1012,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("ItemId", "StreamIndex");
b.HasIndex("StreamType", "ItemId", "Language", "IsExternal");
b.ToTable("MediaStreamInfos");
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
@@ -1078,14 +1080,11 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid?>("Permission_Permissions_Guid")
.HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<Guid?>("UserId")
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Value")
@@ -1094,8 +1093,7 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id");
b.HasIndex("UserId", "Kind")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
.IsUnique();
b.ToTable("Permissions");
@@ -1111,14 +1109,11 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid?>("Preference_Preferences_Guid")
.HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<Guid?>("UserId")
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<string>("Value")
@@ -1129,8 +1124,7 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id");
b.HasIndex("UserId", "Kind")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
.IsUnique();
b.ToTable("Preferences");
@@ -1699,7 +1693,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
.WithMany("Permissions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
@@ -1707,7 +1702,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
.WithMany("Preferences")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using ICU4N.Text;
@@ -173,5 +174,41 @@ namespace Jellyfin.Extensions
return cleaned;
}
/// <summary>
/// Escapes an argument so that it survives command line parsing as a single argument when it is wrapped in double quotes by the caller.
/// </summary>
/// <param name="value">The argument to escape.</param>
/// <returns>The escaped argument.</returns>
public static string EscapeProcessArgument(this string value)
{
ArgumentNullException.ThrowIfNull(value);
var span = value.AsSpan();
if (!span.Contains('"'))
{
var trailing = span.Length - span.TrimEnd('\\').Length;
return trailing == 0 ? value : string.Concat(value, new string('\\', trailing));
}
var escaped = new StringBuilder(value.Length + 8);
var backslashes = 0;
foreach (var character in span)
{
if (character == '\\')
{
backslashes++;
continue;
}
escaped
.Append('\\', character == '"' ? (backslashes * 2) + 1 : backslashes)
.Append(character);
backslashes = 0;
}
return escaped.Append('\\', backslashes * 2).ToString();
}
}
}
+2 -2
View File
@@ -188,8 +188,8 @@ namespace Jellyfin.LiveTv.IO
var commandLineArgs = string.Format(
CultureInfo.InvariantCulture,
"-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"",
inputTempFile,
targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename
inputTempFile.EscapeProcessArgument(),
targetFile.EscapeProcessArgument(),
videoArgs,
GetAudioArgs(mediaSource),
subtitleArgs,
+1 -1
View File
@@ -1262,7 +1262,7 @@ namespace Jellyfin.LiveTv
public Folder GetInternalLiveTvFolder(CancellationToken cancellationToken)
{
var name = _localization.GetLocalizedString("HeaderLiveTV");
var name = _localization.GetServerLocalizedString("HeaderLiveTV");
return _libraryManager.GetNamedView(name, CollectionType.livetv, name);
}
@@ -326,7 +326,7 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
BufferMs = 0,
Container = "ts",
Id = id,
SupportsDirectPlay = false,
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsInfiniteStream = true,
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
@@ -27,6 +28,57 @@ namespace Jellyfin.Controller.Tests.Entities;
public class BaseItemTests
{
[Fact]
public void GetItemByNameFolderName_ShortName_IsKeptAsIs()
{
SetupPassThroughFileSystem();
Assert.Equal("Mairghread Scott", BaseItem.GetItemByNameFolderName("Mairghread Scott."));
}
[Fact]
public void GetItemByNameFolderName_OverlongName_FitsInAPathComponent()
{
SetupPassThroughFileSystem();
// What a provider result that concatenated a whole credit list into one name looks like.
var name = string.Join(", ", Enumerable.Repeat("Jerry Siegel (created by: Superman)", 20));
var folderName = BaseItem.GetItemByNameFolderName(name);
Assert.True(Encoding.UTF8.GetByteCount(folderName) <= 128);
Assert.StartsWith("Jerry Siegel (created by: Superman)", folderName, StringComparison.Ordinal);
}
[Fact]
public void GetItemByNameFolderName_OverlongNamesSharingAPrefix_StayApart()
{
SetupPassThroughFileSystem();
var prefix = new string('a', 200);
Assert.NotEqual(
BaseItem.GetItemByNameFolderName(prefix + "Joe Shuster"),
BaseItem.GetItemByNameFolderName(prefix + "Bob Kane"));
}
[Fact]
public void GetItemByNameFolderName_OverlongName_IsStable()
{
SetupPassThroughFileSystem();
var name = new string('a', 300);
Assert.Equal(BaseItem.GetItemByNameFolderName(name), BaseItem.GetItemByNameFolderName(name));
}
private static void SetupPassThroughFileSystem()
{
var fileSystem = new Mock<IFileSystem>();
fileSystem.Setup(x => x.GetValidFilename(It.IsAny<string>())).Returns((string name) => name);
BaseItem.FileSystem = fileSystem.Object;
}
[Theory]
[InlineData("", "")]
[InlineData("1", "0000000001")]
@@ -75,5 +75,28 @@ namespace Jellyfin.Extensions.Tests
var result = str.AsSpan().RightPart(needle).ToString();
Assert.Equal(expectedResult, result);
}
[Theory]
[InlineData("", "")]
[InlineData("/media/movies/Film.mkv", "/media/movies/Film.mkv")]
[InlineData(@"C:\media\movies\Film.mkv", @"C:\media\movies\Film.mkv")]
[InlineData(@"/media/a""b.mkv", @"/media/a\""b.mkv")]
[InlineData(@"/media/a\""b.mkv", @"/media/a\\\""b.mkv")]
[InlineData(@"/media/a\\""b.mkv", @"/media/a\\\\\""b.mkv")]
[InlineData(@"/media/a\b""c.mkv", @"/media/a\b\""c.mkv")]
[InlineData(@"/media/trailing\", @"/media/trailing\\")]
[InlineData(@"/media/evil\"" -f lavfi -i sine .mkv", @"/media/evil\\\"" -f lavfi -i sine .mkv")]
public void EscapeProcessArgument_ValidInput_Corrects(string input, string expectedResult)
{
Assert.Equal(expectedResult, input.EscapeProcessArgument());
}
[Theory]
[InlineData("/media/movies/Film with spaces.mkv")]
[InlineData(@"C:\media\movies\Film.mkv")]
public void EscapeProcessArgument_NothingToEscape_ReturnsSameInstance(string input)
{
Assert.Same(input, input.EscapeProcessArgument());
}
}
}
@@ -186,6 +186,109 @@ namespace Jellyfin.Model.Tests.Entities
Assert.Null(nullProvider.ProviderIds);
}
[Theory]
[InlineData(nameof(MetadataProvider.Imdb), "tt0113375", true)]
[InlineData(nameof(MetadataProvider.Imdb), "nm0000123", true)]
[InlineData(nameof(MetadataProvider.Imdb), "0113375", true)]
[InlineData(nameof(MetadataProvider.Imdb), "https://www.imdb.com/title/tt0113375", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "11", true)]
[InlineData(nameof(MetadataProvider.Tmdb), "nm0000123", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "0", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "-11", false)]
[InlineData(nameof(MetadataProvider.TmdbCollection), "nm0000123", false)]
[InlineData(nameof(MetadataProvider.AudioDbArtist), "111239", true)]
[InlineData(nameof(MetadataProvider.AudioDbArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", false)]
[InlineData(nameof(MetadataProvider.MusicBrainzArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", true)]
[InlineData(nameof(MetadataProvider.MusicBrainzArtist), "111239", false)]
[InlineData(nameof(MetadataProvider.MusicBrainzAlbum), "not-an-mbid", false)]
[InlineData(nameof(MetadataProvider.Tvdb), "anything-goes", true)]
[InlineData("SomePlugin", "anything-goes", true)]
[InlineData(nameof(MetadataProvider.Tmdb), null, false)]
[InlineData(null, "11", false)]
public void IsValidProviderId_ChecksKnownFormats(string? name, string? value, bool expected)
{
Assert.Equal(expected, ProviderIdsExtensions.IsValidProviderId(name, value));
}
[Fact]
public void TrySetProviderId_ForeignId_False()
{
var provider = new ProviderIdsExtensionsTestsObject();
Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
Assert.Empty(provider.ProviderIds);
}
[Fact]
public void TrySetProviderId_ForeignId_KeepsExisting()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.ProviderIds[MetadataProvider.Tmdb.ToString()] = "11";
Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb));
}
[Theory]
[InlineData(nameof(MetadataProvider.Imdb), " tt0113375 ")]
[InlineData(" Imdb", ExampleImdbId)]
public void TrySetProviderId_SurroundingWhitespace_Trimmed(string name, string value)
{
var provider = new ProviderIdsExtensionsTestsObject();
Assert.True(provider.TrySetProviderId(name, value));
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
}
[Fact]
public void SetProviderIds_ReplacesAll()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.ProviderIds[MetadataProvider.Tvdb.ToString()] = "12345";
provider.SetProviderIds(new Dictionary<string, string>
{
[MetadataProvider.Imdb.ToString()] = ExampleImdbId
});
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
Assert.False(provider.HasProviderId(MetadataProvider.Tvdb));
}
[Fact]
public void SetProviderIds_ForeignId_Dropped()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.SetProviderIds(new Dictionary<string, string>
{
[MetadataProvider.Tmdb.ToString()] = "nm0000123",
[MetadataProvider.Imdb.ToString()] = ExampleImdbId,
[MetadataProvider.Tvdb.ToString()] = string.Empty
});
Assert.False(provider.HasProviderId(MetadataProvider.Tmdb));
Assert.False(provider.HasProviderId(MetadataProvider.Tvdb));
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
}
[Fact]
public void SetProviderIds_Null_Clears()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId;
provider.SetProviderIds(null);
Assert.Empty(provider.ProviderIds);
}
[Fact]
public void SetProviderIds_NullInstance_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensions.SetProviderIds(null!, new Dictionary<string, string>()));
}
[Fact]
public void RemoveProviderId_Null_Remove()
{
@@ -0,0 +1,38 @@
using MediaBrowser.Providers.Manager;
using Xunit;
namespace Jellyfin.Providers.Tests.Manager
{
public class MetadataLanguageUtilsTests
{
[Theory]
[InlineData("es", "es")]
[InlineData("es-ES", "es")]
[InlineData("pt-BR", "pt")]
[InlineData("ES", "es")]
[InlineData(null, null)]
[InlineData("", null)]
public void GetLanguageSubtag_ReturnsLowercasedSubtag(string? language, string? expected)
{
Assert.Equal(expected, MetadataLanguageUtils.GetLanguageSubtag(language));
}
[Theory]
[InlineData("es", "es", true)]
[InlineData("es", "es-ES", true)]
[InlineData("es-MX", "es-ES", true)]
[InlineData("ES", "es", true)]
[InlineData("en", "en", true)]
[InlineData("en", "es-ES", false)]
[InlineData("en", "es", false)]
// An unknown language on either side cannot be judged and is assumed to match
[InlineData(null, "es", true)]
[InlineData("", "es", true)]
[InlineData("en", null, true)]
[InlineData("en", "", true)]
public void MatchesPreferredLanguage_ComparesLanguageSubtag(string? resultLanguage, string? preferredLanguage, bool expected)
{
Assert.Equal(expected, MetadataLanguageUtils.MatchesPreferredLanguage(resultLanguage, preferredLanguage));
}
}
}
@@ -0,0 +1,352 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Providers.Tests.Manager
{
public class MetadataServiceRefreshTests
{
[Theory]
// RemoveOldMetadata is only ever set by an explicit user action - a refresh with "replace all
// metadata", or Identify. A provider failing must not silently downgrade that to a merge: the
// providers that did answer supplied the replacement, and the old values are the wrong match
// the user asked to get rid of.
[InlineData(false)]
[InlineData(true)]
public async Task RefreshWithProviders_ReplaceAllMetadata_ErasesOldDataWhenAProviderAnswers(bool allProvidersSucceed)
{
var item = new Movie
{
Name = "Test Movie",
Overview = "existing overview"
};
// The provider owning the overview fails, so it contributes nothing to the replacement.
var failing = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
failing.Setup(p => p.Name).Returns("Failing");
failing.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.Returns(allProvidersSucceed
? Task.FromResult(new MetadataResult<Movie> { HasMetadata = true, Item = new Movie() })
: Task.FromException<MetadataResult<Movie>>(new FormatException("bad id")));
var succeeding = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
succeeding.Setup(p => p.Name).Returns("Succeeding");
succeeding.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MetadataResult<Movie>
{
HasMetadata = true,
Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
});
var service = new TestMetadataService();
var result = await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true,
RemoveOldMetadata = true
},
[failing.Object, succeeding.Object]).ConfigureAwait(true);
Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures);
Assert.Equal("new tagline", item.Tagline);
Assert.Null(item.Overview);
}
[Fact]
public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataWhenEveryRemoteProviderFails()
{
var item = new Movie
{
Name = "Test Movie",
Overview = "existing overview"
};
// Something has to contribute for the merge to run at all, otherwise the item is never touched
// and the case is moot. The local provider is the replacement the remote ones did not deliver.
var local = new Mock<ILocalMetadataProvider<Movie>>(MockBehavior.Loose);
local.Setup(p => p.Name).Returns("Local");
local.Setup(p => p.GetMetadata(It.IsAny<ItemInfo>(), It.IsAny<IDirectoryService>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MetadataResult<Movie>
{
HasMetadata = true,
Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
});
var remote = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
remote.Setup(p => p.Name).Returns("Failing");
remote.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromException<MetadataResult<Movie>>(new HttpRequestException("unreachable")));
var service = new TestMetadataService();
var result = await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true,
RemoveOldMetadata = true
},
[local.Object, remote.Object]).ConfigureAwait(true);
Assert.Equal(1, result.Failures);
Assert.Equal("new tagline", item.Tagline);
// No remote provider answered, so erasing the overview would lose it for good.
Assert.Equal("existing overview", item.Overview);
}
[Fact]
public async Task RefreshWithProviders_ForeignProviderId_NotStored()
{
var item = new Movie { Name = "Test Movie" };
var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
provider.Setup(p => p.Name).Returns("Provider");
provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
var found = new Movie { Name = "Test Movie" };
found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
found.ProviderIds[MetadataProvider.Imdb.ToString()] = "tt0113375";
return new MetadataResult<Movie> { HasMetadata = true, Item = found };
});
var service = new TestMetadataService();
await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true
},
[provider.Object]).ConfigureAwait(true);
Assert.False(item.HasProviderId(MetadataProvider.Tmdb));
Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb));
}
[Fact]
public async Task RefreshWithProviders_ForeignProviderId_ReplacedInLookupInfo()
{
var item = new Movie { Name = "Test Movie" };
var lookupInfo = new MovieInfo { Name = item.Name };
lookupInfo.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
var answering = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
answering.Setup(p => p.Name).Returns("Answering");
answering.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
var found = new Movie { Name = "Test Movie" };
found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "12345";
return new MetadataResult<Movie> { HasMetadata = true, Item = found };
});
string? tmdbIdSeenBySecondProvider = null;
var following = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
following.Setup(p => p.Name).Returns("Following");
following.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((MovieInfo info, CancellationToken _) =>
{
tmdbIdSeenBySecondProvider = info.GetProviderId(MetadataProvider.Tmdb);
return new MetadataResult<Movie> { HasMetadata = false };
});
var service = new TestMetadataService();
await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
lookupInfo,
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true
},
[answering.Object, following.Object]).ConfigureAwait(true);
// The stored id cannot be a TMDb one, so the provider that still has to run must get the id
// that was just found instead of failing on the same bad one.
Assert.Equal("12345", tmdbIdSeenBySecondProvider);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RefreshWithProviders_ForeignPersonProviderId_NotStored(bool replaceAllMetadata)
{
var item = new Movie { Name = "Test Movie" };
var existing = new MetadataResult<Movie> { Item = item };
existing.AddPerson(new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor });
var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
provider.Setup(p => p.Name).Returns("Provider");
provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
var person = new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor };
person.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
person.ProviderIds[MetadataProvider.Imdb.ToString()] = "nm0000123";
var found = new MetadataResult<Movie> { HasMetadata = true, Item = new Movie { Name = "Test Movie" } };
found.AddPerson(person);
return found;
});
var service = new TestMetadataService();
await service.RefreshWithProvidersInternal(
existing,
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = replaceAllMetadata
},
[provider.Object]).ConfigureAwait(true);
var mergedPerson = Assert.Single(existing.People);
Assert.False(mergedPerson.HasProviderId(MetadataProvider.Tmdb));
Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb));
}
[Theory]
[InlineData(MetadataRefreshMode.FullRefresh, true)]
[InlineData(MetadataRefreshMode.Default, false)]
public async Task RefreshMetadata_ProvidersFoundNothing_PersistsRefreshDateOnFullRefresh(MetadataRefreshMode mode, bool expectSaved)
{
var item = new TestItem
{
Id = Guid.NewGuid(),
Name = "Test Item",
PreferredMetadataLanguage = "en",
PreferredMetadataCountryCode = "US",
DateLastRefreshed = DateTime.UtcNow.AddDays(-60),
DateLastSaved = DateTime.UtcNow.AddDays(-60)
};
item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
var stampBefore = item.DateLastRefreshed;
var provider = new Mock<IRemoteMetadataProvider<TestItem, ItemLookupInfo>>(MockBehavior.Loose);
provider.Setup(p => p.Name).Returns("Provider");
provider.Setup(p => p.GetMetadata(It.IsAny<ItemLookupInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MetadataResult<TestItem> { HasMetadata = false });
var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose);
libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
var providerManager = new Mock<IProviderManager>(MockBehavior.Loose);
providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>()))
.Returns(Array.Empty<IImageProvider>());
providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
.Returns(new[] { (IMetadataProvider<TestItem>)provider.Object });
providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
.Returns(Array.Empty<IMetadataSaver>());
var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose);
itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true);
var service = new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object);
await service.RefreshMetadata(
item,
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = mode,
ImageRefreshMode = mode
},
CancellationToken.None).ConfigureAwait(true);
// Nothing was found, so on a full refresh the advanced stamp is the only reason to write the row.
Assert.Equal(expectSaved, item.Saved);
if (expectSaved)
{
Assert.True(item.DateLastRefreshed > stampBefore);
}
}
/// <summary>
/// Stands in for a real item so the refresh stays off the shared BaseItem statics, which other
/// test classes in this assembly overwrite while xUnit runs them in parallel.
/// </summary>
internal sealed class TestItem : BaseItem
{
public bool Saved { get; private set; }
public override bool RequiresRefresh() => false;
public override bool IsSaveLocalMetadataEnabled() => false;
public override string CreatePresentationUniqueKey() => Id.ToString("N", CultureInfo.InvariantCulture);
public override ItemUpdateType OnMetadataChanged() => ItemUpdateType.None;
public override bool BeforeMetadataRefresh(bool replaceAllMetadata) => false;
public override Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
{
Saved = true;
return Task.CompletedTask;
}
}
private sealed class TestMetadataService : MetadataService<Movie, MovieInfo>
{
public TestMetadataService()
: base(
Mock.Of<IServerConfigurationManager>(),
NullLogger<MetadataService<Movie, MovieInfo>>.Instance,
Mock.Of<IProviderManager>(),
Mock.Of<IFileSystem>(),
Mock.Of<ILibraryManager>(),
Mock.Of<IExternalDataManager>(),
Mock.Of<IItemRepository>())
{
}
public Task<RefreshResult> RefreshWithProvidersInternal(
MetadataResult<Movie> metadata,
MovieInfo id,
MetadataRefreshOptions options,
ICollection<IMetadataProvider> providers)
=> RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None);
}
private sealed class TestItemMetadataService : MetadataService<TestItem, ItemLookupInfo>
{
public TestItemMetadataService(ILibraryManager libraryManager, IProviderManager providerManager, IItemRepository itemRepository)
: base(
Mock.Of<IServerConfigurationManager>(),
NullLogger<MetadataService<TestItem, ItemLookupInfo>>.Instance,
providerManager,
Mock.Of<IFileSystem>(),
libraryManager,
Mock.Of<IExternalDataManager>(),
itemRepository)
{
}
}
}
}
@@ -0,0 +1,59 @@
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Music;
using Xunit;
namespace Jellyfin.Providers.Tests.Music;
public static class AlbumInfoExtensionsTests
{
private const string ExampleMbid = "59b5a40b-e2fd-3f18-a218-e8c9aae12ab5";
private const string SongMbid = "6c301dbd-6ccb-3403-a6c4-6a22240a0297";
[Theory]
[InlineData(ExampleMbid, ExampleMbid)]
// Another provider's id under a MusicBrainz key reads as no id, so the caller searches instead of
// handing a value the MusicBrainz client throws on.
[InlineData("111239", null)]
[InlineData("", null)]
public static void GetReleaseId_OnlyReturnsMbids(string id, string? expected)
{
var info = new AlbumInfo();
info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = id;
Assert.Equal(expected, info.GetReleaseId());
}
[Fact]
public static void GetReleaseId_ForeignId_FallsBackToSongs()
{
var song = new SongInfo();
song.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = SongMbid;
var info = new AlbumInfo { SongInfos = [song] };
info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = "111239";
Assert.Equal(SongMbid, info.GetReleaseId());
}
[Fact]
public static void GetMusicBrainzArtistId_ForeignId_FallsBackToArtistIds()
{
var info = new AlbumInfo();
info.ProviderIds[MetadataProvider.MusicBrainzAlbumArtist.ToString()] = "111239";
info.ArtistProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = ExampleMbid;
Assert.Equal(ExampleMbid, info.GetMusicBrainzArtistId());
}
[Theory]
[InlineData(ExampleMbid, ExampleMbid)]
[InlineData("111239", null)]
public static void GetMusicBrainzArtistId_ArtistInfo_OnlyReturnsMbids(string id, string? expected)
{
var info = new ArtistInfo();
info.ProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = id;
Assert.Equal(expected, info.GetMusicBrainzArtistId());
}
}
@@ -0,0 +1,81 @@
using System.Linq;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Providers.Plugins.Omdb;
using Xunit;
namespace Jellyfin.Providers.Tests.Omdb
{
public class OmdbProviderTests
{
[Fact]
public void AddPeople_CommaSeparatedList_SplitsIntoIndividualPeople()
{
var result = new MetadataResult<Movie>();
OmdbProvider.AddPeople(result, "Philip G. Epstein, Julius J. Epstein, Howard Koch", PersonKind.Writer);
Assert.Equal(
new[] { "Philip G. Epstein", "Julius J. Epstein", "Howard Koch" },
result.People!.Select(p => p.Name));
Assert.All(result.People!, p => Assert.Equal(PersonKind.Writer, p.Type));
}
[Fact]
public void AddPeople_RoleAnnotations_AreStrippedAndDeduplicated()
{
var result = new MetadataResult<Movie>();
OmdbProvider.AddPeople(result, "Mari Okada (screenplay), Mari Okada (story), Jun'ichi Satô (screenplay), Jun'ichi Satô (story)", PersonKind.Writer);
Assert.Equal(
new[] { "Mari Okada", "Jun'ichi Satô" },
result.People!.Select(p => p.Name));
}
[Theory]
[InlineData(
"Jerry Siegel (created by: Superman, Superboy), Bob Kane (created by: Batman)",
"Jerry Siegel|Bob Kane")]
[InlineData("Alan Moore (created by: John Constantine)", "Alan Moore")]
public void AddPeople_CommaInsideAnAnnotation_StaysOneCredit(string credits, string expected)
{
var result = new MetadataResult<Movie>();
OmdbProvider.AddPeople(result, credits, PersonKind.Writer);
Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name));
}
[Theory]
[InlineData("Jack Salvatore, Jr.", "Jack Salvatore, Jr.")]
[InlineData("Efrem Zimbalist, Jr., Tom Hanks", "Efrem Zimbalist, Jr.|Tom Hanks")]
[InlineData("Tom Hanks, Sammy Davis, Jr", "Tom Hanks|Sammy Davis, Jr")]
[InlineData("Harold Ramis, Ken Griffey, III (voice)", "Harold Ramis|Ken Griffey, III")]
[InlineData("Robert Downey Jr., Gwyneth Paltrow", "Robert Downey Jr.|Gwyneth Paltrow")]
[InlineData("Jr., Tom Hanks", "Jr.|Tom Hanks")]
public void AddPeople_GenerationalSuffix_StaysWithItsName(string credits, string expected)
{
var result = new MetadataResult<Movie>();
OmdbProvider.AddPeople(result, credits, PersonKind.Actor);
Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("(uncredited)")]
public void AddPeople_NoUsableName_AddsNothing(string? credits)
{
var result = new MetadataResult<Movie>();
OmdbProvider.AddPeople(result, credits!, PersonKind.Actor);
Assert.Null(result.People);
}
}
}
@@ -0,0 +1,105 @@
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Plugins.Tmdb;
using TMDbLib.Objects.TvShows;
using Xunit;
namespace Jellyfin.Providers.Tests.Tmdb
{
public class TmdbUtilsCastTests
{
private static readonly PluginConfiguration _config = new() { MaxCastMembers = 10 };
[Fact]
public void MapAggregateCast_MemberWithSeveralRoles_YieldsOneCreditPerRole()
{
var cast = new List<CastAggregate>
{
CreateAggregate("Megumi Toyoguchi", 1, 0, ("Tabby (voice)", 3), ("Mimiru (voice)", 12))
};
var people = TmdbUtils.MapAggregateCast(cast, _config, _ => null).ToArray();
// The character they played the longest comes first, which is their own billing.
Assert.Equal(["Mimiru (voice)", "Tabby (voice)"], people.Select(p => p.Role));
Assert.All(people, p => Assert.Equal("Megumi Toyoguchi", p.Name));
Assert.All(people, p => Assert.Equal(PersonKind.Actor, p.Type));
Assert.All(people, p => Assert.Equal("1", p.GetProviderId(MetadataProvider.Tmdb)));
}
[Fact]
public void MapAggregateCast_MemberWithoutARole_IsStillCredited()
{
var cast = new List<CastAggregate> { CreateAggregate("Uncredited Actor", 2, 0) };
var person = Assert.Single(TmdbUtils.MapAggregateCast(cast, _config, _ => null));
Assert.Equal(string.Empty, person.Role);
}
[Fact]
public void MapAggregateCast_MoreThanConfigured_KeepsTheTopBilled()
{
var cast = Enumerable.Range(0, 5)
.Select(i => CreateAggregate($"Actor {4 - i}", i + 1, 4 - i, ($"Role {4 - i}", 1)))
.ToList();
var people = TmdbUtils.MapAggregateCast(cast, new PluginConfiguration { MaxCastMembers = 2 }, _ => null);
Assert.Equal(["Actor 0", "Actor 1"], people.Select(p => p.Name));
}
[Fact]
public void MapAggregateCast_HideMissingCastMembers_DropsTheOnesWithoutAProfile()
{
var withProfile = CreateAggregate("Has Profile", 1, 0, ("Hero", 1));
withProfile.ProfilePath = "/profile.jpg";
var cast = new List<CastAggregate> { withProfile, CreateAggregate("No Profile", 2, 1, ("Villain", 1)) };
var people = TmdbUtils.MapAggregateCast(
cast,
new PluginConfiguration { MaxCastMembers = 10, HideMissingCastMembers = true },
_ => null);
Assert.Equal(["Has Profile"], people.Select(p => p.Name));
}
[Fact]
public void MapCast_FlatCredits_YieldOneCreditEach()
{
var cast = new List<Cast>
{
new() { Name = "Kevin Conroy", Id = 1, Order = 0, Character = " Batman (voice) " },
new() { Name = " ", Id = 2, Order = 1, Character = "Nobody" }
};
var person = Assert.Single(TmdbUtils.MapCast(cast, _config, _ => null));
Assert.Equal("Kevin Conroy", person.Name);
Assert.Equal("Batman (voice)", person.Role);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void MapCast_NoCast_YieldsNothing(bool aggregate)
{
Assert.Empty(aggregate
? TmdbUtils.MapAggregateCast(null, _config, _ => null)
: TmdbUtils.MapCast(null, _config, _ => null));
}
private static CastAggregate CreateAggregate(string name, int id, int order, params (string Character, int Episodes)[] roles)
{
return new CastAggregate
{
Name = name,
Id = id,
Order = order,
Roles = roles.Select(role => new CastRole { Character = role.Character, EpisodeCount = role.Episodes }).ToList()
};
}
}
}
@@ -1,3 +1,5 @@
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Plugins.Tmdb;
using Xunit;
@@ -34,5 +36,40 @@ namespace Jellyfin.Providers.Tests.Tmdb
{
Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage));
}
[Theory]
[InlineData("11", true, 11)]
// An id another provider filed under the TMDb key must not throw, it is simply not a TMDb id.
[InlineData("nm0000123", false, 0)]
[InlineData("tt0113375", false, 0)]
[InlineData("11.0", false, 0)]
[InlineData("-11", false, 0)]
[InlineData("0", false, 0)]
[InlineData("", false, 0)]
[InlineData(null, false, 0)]
public static void TryParseTmdbId_OnlyAcceptsTmdbIds(string? value, bool expected, int expectedId)
{
Assert.Equal(expected, TmdbUtils.TryParseTmdbId(value, out var tmdbId));
Assert.Equal(expectedId, tmdbId);
}
[Theory]
[InlineData("11", true, 11)]
[InlineData("nm0000123", false, 0)]
public static void TryGetTmdbId_OnlyAcceptsTmdbIds(string value, bool expected, int expectedId)
{
var item = new Movie();
item.ProviderIds[MetadataProvider.Tmdb.ToString()] = value;
Assert.Equal(expected, item.TryGetTmdbId(out var tmdbId));
Assert.Equal(expectedId, tmdbId);
}
[Fact]
public static void TryGetTmdbId_NoId_False()
{
Assert.False(new Movie().TryGetTmdbId(out var tmdbId));
Assert.Equal(0, tmdbId);
}
}
}
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using Emby.Server.Implementations.Dto;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Common;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Drawing;
@@ -21,11 +23,13 @@ namespace Jellyfin.Server.Implementations.Tests.Dto;
public class DtoServiceTests
{
private readonly Mock<ILibraryManager> _libraryManagerMock;
private readonly Mock<IUserDataManager> _userDataManagerMock;
private readonly DtoService _dtoService;
public DtoServiceTests()
{
_libraryManagerMock = new Mock<ILibraryManager>();
_userDataManagerMock = new Mock<IUserDataManager>();
var imageProcessor = new Mock<IImageProcessor>();
// Deterministic tag derived from the image so each item gets a distinct, assertable tag.
@@ -42,7 +46,7 @@ public class DtoServiceTests
_dtoService = new DtoService(
NullLogger<DtoService>.Instance,
_libraryManagerMock.Object,
new Mock<IUserDataManager>().Object,
_userDataManagerMock.Object,
imageProcessor.Object,
new Mock<IProviderManager>().Object,
new Mock<IRecordingsManager>().Object,
@@ -105,6 +109,57 @@ public class DtoServiceTests
Assert.Null(dto.ParentPrimaryImageItemId);
}
[Fact]
public void GetBaseItemDtos_SeasonWithNoRealEpisodes_ReportsVirtualEpisodesAsChildCount()
{
// No episode has aired yet, so RecursiveItemCount is 0. ChildCount must still report the
// virtual episodes clients get back for the season. This deliberately does not track
// Season.IsVirtualItem: that flag is recomputed only on a full refresh, so a season can
// carry it while already holding real episodes.
var (season, user) = BuildSeason(playedCount: 0, totalCount: 0, childCount: 10);
var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount, ItemFields.RecursiveItemCount] };
var dto = _dtoService.GetBaseItemDtos([season], options, user, skipVisibilityCheck: true)[0];
Assert.Equal(0, dto.RecursiveItemCount);
Assert.Equal(10, dto.ChildCount);
}
[Fact]
public void GetBaseItemDtos_SeasonWithRealEpisodes_KeepsRecursiveItemCountAsChildCount()
{
var (season, user) = BuildSeason(playedCount: 2, totalCount: 9, childCount: 11);
var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount, ItemFields.RecursiveItemCount] };
var dto = _dtoService.GetBaseItemDtos([season], options, user, skipVisibilityCheck: true)[0];
Assert.Equal(9, dto.RecursiveItemCount);
// The shortcut still wins over the batched child count, which also counts virtual episodes.
Assert.Equal(9, dto.ChildCount);
}
private (Season Season, User User) BuildSeason(int playedCount, int totalCount, int childCount)
{
var user = new User("user", "auth-provider", "reset-provider");
var season = new Season { Id = Guid.NewGuid(), Name = "Season 2", SeriesId = Guid.NewGuid() };
_userDataManagerMock
.Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), user))
.Returns(new Dictionary<Guid, UserItemData> { [season.Id] = new UserItemData { Key = "key" } });
_userDataManagerMock
.Setup(x => x.GetResumeUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), user))
.Returns(new Dictionary<Guid, VersionResumeData>());
_libraryManagerMock
.Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny<IReadOnlyList<Guid>>(), user))
.Returns(new Dictionary<Guid, (int Played, int Total)> { [season.Id] = (playedCount, totalCount) });
_libraryManagerMock
.Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<Guid?>()))
.Returns(new Dictionary<Guid, int> { [season.Id] = childCount });
return (season, user);
}
private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true)
{
// Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
@@ -4,11 +4,6 @@ using System;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
@@ -18,22 +13,10 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
/// and evaluate correctly on the SQLite provider.
/// </summary>
public sealed class AlternateVersionQueryTranslationTests : IDisposable
public sealed class AlternateVersionQueryTranslationTests : SqliteDbTestFixture
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
public AlternateVersionQueryTranslationTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
}
[Fact]
@@ -220,18 +203,4 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
ctx.SaveChanges();
return (user.Id, primary.Id, versionA.Id, versionB.Id);
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
public void Dispose()
{
_connection.Dispose();
}
}

Some files were not shown because too many files have changed in this diff Show More