Merge branch 'jellyfin:master' into BDMV-pgs
This commit is contained in:
@@ -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.
|
||||
|
||||
+64
-15
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<System.String>.</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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+231
-120
@@ -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/>
|
||||
|
||||
+4
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
|
||||
{
|
||||
builder
|
||||
.HasIndex(p => new { p.UserId, p.Kind })
|
||||
.HasFilter("[UserId] IS NOT NULL")
|
||||
.IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
+1813
File diff suppressed because it is too large
Load Diff
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1807
File diff suppressed because it is too large
Load Diff
+120
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-15
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-32
@@ -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
Reference in New Issue
Block a user