Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4910aafa1a | |||
| 726ae93a92 | |||
| 0cec5661e0 | |||
| fe73b1a949 | |||
| 80324b19fb | |||
| 05844d60c1 | |||
| eafe5ba6ed | |||
| 7ccce8e0e7 | |||
| 40ab2c4284 | |||
| d1dda7f6c5 | |||
| ff36560575 | |||
| e9abaa519c | |||
| 359e8069d0 | |||
| 7d6633ad1e | |||
| 11adad0e67 | |||
| 420d44f638 | |||
| 9fe5a53e47 | |||
| 2996f726c1 | |||
| 37db4bda53 | |||
| 0d78e44633 | |||
| 4962e4ec33 | |||
| 07e97c0ee9 | |||
| 24288e79a9 | |||
| 27d898e59e | |||
| 95281a2205 | |||
| 5a2f45ab4b | |||
| fbb0f1afbc | |||
| ea5f83328d | |||
| a45e66d43c | |||
| 6ad1e341b1 | |||
| b46e66627c | |||
| 7116d3bb72 | |||
| 99f21f1662 | |||
| 9126de26c0 | |||
| 75df54611a | |||
| ceeeaaab8e | |||
| 6da85a0aaa | |||
| 2aad6047c8 | |||
| 1cc490fb19 | |||
| cefa78fc1d | |||
| 0c560b22ce | |||
| 911ac3769c | |||
| 4de43d36dd | |||
| b1e3cf1341 | |||
| dd7de41878 |
@@ -192,7 +192,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
|
||||
if (folderIds.Count > 0)
|
||||
{
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id);
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -696,7 +700,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
return count;
|
||||
}
|
||||
|
||||
// Fall back to individual query for special cases (Series, Season, etc.)
|
||||
// Only reached when no batch was computed: the batch holds an entry for every folder it
|
||||
// was asked about, zero included.
|
||||
return folder.GetChildCount(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -1745,9 +1745,9 @@ namespace Emby.Server.Implementations.Library
|
||||
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
|
||||
}
|
||||
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
|
||||
{
|
||||
return _countService.GetChildCountBatch(parentIds, userId);
|
||||
return _countService.GetChildCountBatch(parentIds, user);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -1984,18 +1984,10 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
// Playlists and BoxSets store their contents in LinkedChildren and never
|
||||
// populate AncestorIds for those items, so a recursive AncestorIds query
|
||||
// would return zero rows. Resolve to the linked child IDs up front and
|
||||
// route through the existing indexed ItemIds filter.
|
||||
query.ItemIds = folder.LinkedChildren
|
||||
.Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
|
||||
.Select(lc => lc.ItemId!.Value)
|
||||
.ToArray();
|
||||
|
||||
// Empty linked-children should still return empty rather than scanning everything.
|
||||
if (query.ItemIds.Length == 0)
|
||||
{
|
||||
query.ItemIds = [Guid.NewGuid()];
|
||||
}
|
||||
// would return zero rows. Filter by the descendant set instead, which follows
|
||||
// the links and keeps descending, so a linked folder contributes what is below
|
||||
// it as well - the episodes of a Series added to a collection, for example.
|
||||
query.DescendantOfId = folder.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -3852,7 +3844,9 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
|
||||
?? throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
|
||||
CreateShortcut(virtualFolderPath, pathInfo);
|
||||
|
||||
@@ -3873,7 +3867,9 @@ namespace Emby.Server.Implementations.Library
|
||||
ArgumentNullException.ThrowIfNull(mediaPath);
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
|
||||
?? throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
|
||||
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
|
||||
|
||||
@@ -3912,9 +3908,9 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
|
||||
var path = Path.Combine(rootFolderPath, name);
|
||||
var path = FileSystemHelper.GetChildPath(rootFolderPath, name);
|
||||
|
||||
if (!Directory.Exists(path))
|
||||
if (path is null || !Directory.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("The media folder does not exist");
|
||||
}
|
||||
@@ -3978,9 +3974,9 @@ namespace Emby.Server.Implementations.Library
|
||||
ArgumentException.ThrowIfNullOrEmpty(mediaPath);
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName);
|
||||
|
||||
if (!Directory.Exists(virtualFolderPath))
|
||||
if (virtualFolderPath is null || !Directory.Exists(virtualFolderPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
|
||||
@@ -129,6 +129,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
|
||||
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||
|
||||
// Anime databases model a single cour as its own entry, so a multi-season
|
||||
// series maps to one of these ids per season rather than one per series.
|
||||
var anidbId = justName.GetAttributeValue("anidbid");
|
||||
item.TrySetProviderId("AniDB", anidbId);
|
||||
|
||||
var aniListId = justName.GetAttributeValue("anilistid");
|
||||
item.TrySetProviderId("AniList", aniListId);
|
||||
|
||||
var aniSearchId = justName.GetAttributeValue("anisearchid");
|
||||
item.TrySetProviderId("AniSearch", aniSearchId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,13 +112,12 @@ public class SearchManager : ISearchManager
|
||||
return externalResults;
|
||||
}
|
||||
|
||||
var internalResults = await internalTask.ConfigureAwait(false);
|
||||
if (_internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||
}
|
||||
|
||||
return internalResults;
|
||||
return await internalTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||
@@ -144,17 +143,16 @@ public class SearchManager : ISearchManager
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (allowedCount == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (allowedIds.Count == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
|
||||
if (filtered.Count < candidates.Count)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the access filter that decides which items a similar-items lookup may return for a user.
|
||||
/// </summary>
|
||||
internal static class SimilarItemsAccessFilter
|
||||
{
|
||||
private static readonly BaseItemKind[] _itemByNameKinds =
|
||||
[
|
||||
BaseItemKind.Person,
|
||||
BaseItemKind.Genre,
|
||||
BaseItemKind.MusicGenre,
|
||||
BaseItemKind.MusicArtist,
|
||||
BaseItemKind.Studio
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Builds an access filter carrying the user's library access and parental restrictions.
|
||||
/// </summary>
|
||||
/// <param name="user">The user the lookup runs for.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <returns>The access filter.</returns>
|
||||
public static InternalItemsQuery Build(User user, ILibraryManager libraryManager)
|
||||
{
|
||||
// IncludeItemTypes is read only for the by-name exemption here; the caller applies this
|
||||
// filter through ApplyAccessFiltering, which does not translate it into a type restriction.
|
||||
var accessFilter = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = _itemByNameKinds
|
||||
};
|
||||
|
||||
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
return accessFilter;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ using System.Text.Json;
|
||||
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.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
@@ -16,11 +18,13 @@ using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
@@ -35,6 +39,8 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private ISimilarItemsProvider[] _similarItemsProviders = [];
|
||||
|
||||
/// <summary>
|
||||
@@ -45,18 +51,24 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
public SimilarItemsManager(
|
||||
ILogger<SimilarItemsManager> logger,
|
||||
IServerApplicationPaths appPaths,
|
||||
ILibraryManager libraryManager,
|
||||
IFileSystem fileSystem,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_libraryManager = libraryManager;
|
||||
_fileSystem = fileSystem;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -230,11 +242,64 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
}
|
||||
}
|
||||
|
||||
return allResults
|
||||
var ordered = allResults
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.Take(requestedLimit)
|
||||
.ToList();
|
||||
|
||||
return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync(
|
||||
IReadOnlyList<BaseItem> candidates,
|
||||
User? user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (candidates.Count == 0 || user is null)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager);
|
||||
|
||||
// No accessible libraries means nothing to compare against, and an empty TopParentIds set
|
||||
// would disable the filter rather than reject everything.
|
||||
if (accessFilter.TopParentIds.Length == 0)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.Id)];
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.WhereOneOrMany(candidateIds, e => e.Id);
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (allowedCount == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.Id)).ToList();
|
||||
_logger.LogDebug(
|
||||
"Dropped {Dropped} of {Total} similar-item candidates due to user access filtering",
|
||||
candidates.Count - filtered.Count,
|
||||
candidates.Count);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -376,19 +441,39 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
|
||||
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter once across every category rather than per baseline, so a batch provider costs one
|
||||
// access query no matter how many categories it produced.
|
||||
var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList();
|
||||
var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
HashSet<Guid>? allowedIds = allowed.Count == allItems.Count
|
||||
? null
|
||||
: [.. allowed.Select(item => item.Id)];
|
||||
|
||||
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
|
||||
foreach (var baseline in baselineItems)
|
||||
{
|
||||
if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
|
||||
if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0)
|
||||
{
|
||||
recommendations.Add(new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = baseline.Name,
|
||||
CategoryId = baseline.Id,
|
||||
RecommendationType = recommendationType,
|
||||
Items = similar
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowedIds is not null)
|
||||
{
|
||||
similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList();
|
||||
if (similar.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
recommendations.Add(new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = baseline.Name,
|
||||
CategoryId = baseline.Id,
|
||||
RecommendationType = recommendationType,
|
||||
Items = similar
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
|
||||
@@ -106,5 +106,11 @@
|
||||
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
||||
"CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
|
||||
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
|
||||
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.",
|
||||
"LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}",
|
||||
"NameExtraDeletedScene": "Выдаленая сцэна",
|
||||
"NameExtraInterview": "Інтэрв'ю",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraScene": "Сцэна",
|
||||
"NameExtraTrailer": "Трэйлер"
|
||||
}
|
||||
|
||||
@@ -120,5 +120,6 @@
|
||||
"NameExtraThemeSong": "Theme Song",
|
||||
"NameExtraThemeVideo": "Theme Video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra"
|
||||
"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.",
|
||||
@@ -118,7 +118,7 @@
|
||||
"TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað",
|
||||
"TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.",
|
||||
"NameExtraThemeVideo": "Eyðkenniskykmynd",
|
||||
"NameExtraDeletedScene": "Úrtikin mynd (scena)",
|
||||
"NameExtraDeletedScene": "Úrtikin mynd",
|
||||
"NameExtraScene": "Mynd (scena)",
|
||||
"NameExtraUnknown": "Eykatilfar",
|
||||
"Original": "Upprunalig(t/ur)"
|
||||
|
||||
@@ -109,6 +109,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
var dupQuery = context.Peoples
|
||||
.GroupBy(e => new { e.Name, e.PersonType })
|
||||
.Where(e => e.Count() > 1)
|
||||
.OrderBy(e => e.Key.Name)
|
||||
.ThenBy(e => e.Key.PersonType)
|
||||
.Select(e => e.Select(f => f.Id).ToArray());
|
||||
|
||||
var total = dupQuery.Count();
|
||||
|
||||
@@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session
|
||||
{
|
||||
if (!session.SessionControllers.Any(i => i.IsSessionActive))
|
||||
{
|
||||
var key = GetSessionKey(session.Client, session.DeviceId);
|
||||
var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
|
||||
|
||||
_activeConnections.TryRemove(key, out _);
|
||||
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
|
||||
@@ -369,7 +369,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
var key = GetSessionKey(session.Client, session.DeviceId);
|
||||
var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
|
||||
|
||||
_activeConnections.TryRemove(key, out _);
|
||||
|
||||
@@ -475,8 +475,11 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSessionKey(string appName, string deviceId)
|
||||
=> appName + deviceId;
|
||||
// The user is part of the key because the client name and the device id are taken from the
|
||||
// request headers and are not bound to the access token. Without it, any authenticated user
|
||||
// could claim another user's client/device pair and take over their session.
|
||||
private static string GetSessionKey(string appName, string deviceId, Guid userId)
|
||||
=> appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection.
|
||||
@@ -500,7 +503,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var key = GetSessionKey(appName, deviceId);
|
||||
var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
|
||||
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
||||
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
|
||||
if (ReferenceEquals(newSession, sessionInfo))
|
||||
@@ -1537,11 +1540,52 @@ namespace Emby.Server.Implementations.Session
|
||||
return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken);
|
||||
}
|
||||
|
||||
private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
|
||||
private void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(controllingSession);
|
||||
|
||||
var controllingUserId = controllingSession.UserId;
|
||||
|
||||
// Controlling a session is always allowed when:
|
||||
// - the caller has no associated user (an API key, which is a privileged context),
|
||||
// - the target session is public (has no owning user), or
|
||||
// - the caller's user is associated with the target session.
|
||||
// Controlling a session owned by a different user requires the
|
||||
// EnableRemoteControlOfOtherUsers permission.
|
||||
if (controllingUserId.IsEmpty()
|
||||
|| session.UserId.IsEmpty()
|
||||
|| session.ContainsUser(controllingUserId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var controllingUser = _userManager.GetUserById(controllingUserId);
|
||||
if (controllingUser is null
|
||||
|| !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
|
||||
{
|
||||
throw new SecurityException("The current user does not have permission to remote control other users.");
|
||||
}
|
||||
}
|
||||
|
||||
private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId)
|
||||
{
|
||||
var controllingUserId = controllingSession.UserId;
|
||||
|
||||
// Playback reported by a session is also written to the user data of its additional users,
|
||||
// so attaching anyone but the calling user requires administrative privileges.
|
||||
if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var controllingUser = _userManager.GetUserById(controllingUserId);
|
||||
if (controllingUser is null
|
||||
|| !controllingUser.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
throw new SecurityException("The current user does not have permission to attach another user to a session.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1559,16 +1603,24 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Adds the additional user.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception>
|
||||
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
|
||||
public void AddAdditionalUser(string sessionId, Guid userId)
|
||||
public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
var controllingSession = GetSession(controllingSessionId);
|
||||
AssertCanControl(session, controllingSession);
|
||||
AssertCanAttachUser(controllingSession, userId);
|
||||
}
|
||||
|
||||
if (session.UserId.Equals(userId))
|
||||
{
|
||||
throw new ArgumentException("The requested user is already the primary user of the session.");
|
||||
@@ -1576,7 +1628,8 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId)))
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
var user = _userManager.GetUserById(userId)
|
||||
?? throw new ArgumentException("The requested user does not exist.");
|
||||
var newUser = new SessionUserInfo
|
||||
{
|
||||
UserId = userId,
|
||||
@@ -1590,16 +1643,22 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Removes the additional user.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
|
||||
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
|
||||
public void RemoveAdditionalUser(string sessionId, Guid userId)
|
||||
public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
if (session.UserId.Equals(userId))
|
||||
{
|
||||
throw new ArgumentException("The requested user is already the primary user of the session.");
|
||||
@@ -1803,14 +1862,21 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Reports the capabilities.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="capabilities">The capabilities.</param>
|
||||
public void ReportCapabilities(string sessionId, ClientCapabilities capabilities)
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
|
||||
public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
ReportCapabilities(session, capabilities, true);
|
||||
}
|
||||
|
||||
@@ -1905,13 +1971,18 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReportNowViewingItem(string sessionId, string itemId)
|
||||
public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(itemId);
|
||||
|
||||
var item = _libraryManager.GetItemById(new Guid(itemId));
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
session.NowViewingItem = GetItemInfo(item, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,12 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = null;
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
@@ -193,31 +199,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
|
||||
var result = _libraryManager.GetArtists(query);
|
||||
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, itemCounts) = i;
|
||||
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes.Length != 0)
|
||||
{
|
||||
dto.ChildCount = itemCounts.ItemCount;
|
||||
dto.ProgramCount = itemCounts.ProgramCount;
|
||||
dto.SeriesCount = itemCounts.SeriesCount;
|
||||
dto.EpisodeCount = itemCounts.EpisodeCount;
|
||||
dto.MovieCount = itemCounts.MovieCount;
|
||||
dto.TrailerCount = itemCounts.TrailerCount;
|
||||
dto.AlbumCount = itemCounts.AlbumCount;
|
||||
dto.SongCount = itemCounts.SongCount;
|
||||
dto.ArtistCount = itemCounts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
query.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -298,6 +280,12 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = null;
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
@@ -365,31 +353,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
|
||||
var result = _libraryManager.GetAlbumArtists(query);
|
||||
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, itemCounts) = i;
|
||||
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes.Length != 0)
|
||||
{
|
||||
dto.ChildCount = itemCounts.ItemCount;
|
||||
dto.ProgramCount = itemCounts.ProgramCount;
|
||||
dto.SeriesCount = itemCounts.SeriesCount;
|
||||
dto.EpisodeCount = itemCounts.EpisodeCount;
|
||||
dto.MovieCount = itemCounts.MovieCount;
|
||||
dto.TrailerCount = itemCounts.TrailerCount;
|
||||
dto.AlbumCount = itemCounts.AlbumCount;
|
||||
dto.SongCount = itemCounts.SongCount;
|
||||
dto.ArtistCount = itemCounts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
query.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -97,6 +97,12 @@ public class GenresController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
@@ -143,8 +149,7 @@ public class GenresController : BaseJellyfinApiController
|
||||
result = _libraryManager.GetGenres(query);
|
||||
}
|
||||
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,6 +14,7 @@ using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@@ -122,12 +123,14 @@ public class LibraryStructureController : BaseJellyfinApiController
|
||||
/// <param name="newName">The new name.</param>
|
||||
/// <param name="refreshLibrary">Whether to refresh the library.</param>
|
||||
/// <response code="204">Folder renamed.</response>
|
||||
/// <response code="400">The new name is not a valid library name.</response>
|
||||
/// <response code="404">Library doesn't exist.</response>
|
||||
/// <response code="409">Library already exists.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
|
||||
/// <returns>A <see cref="NoContentResult"/> on success, a <see cref="BadRequestResult"/> if the new name is invalid, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
|
||||
/// <exception cref="ArgumentNullException">The new name may not be null.</exception>
|
||||
[HttpPost("Name")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public ActionResult RenameVirtualFolder(
|
||||
@@ -147,10 +150,15 @@ public class LibraryStructureController : BaseJellyfinApiController
|
||||
|
||||
var rootFolderPath = _appPaths.DefaultUserViewsPath;
|
||||
|
||||
var currentPath = Path.Combine(rootFolderPath, name);
|
||||
var newPath = Path.Combine(rootFolderPath, newName);
|
||||
// Both names are caller supplied, so they have to be confined to the libraries root.
|
||||
var newPath = FileSystemHelper.GetChildPath(rootFolderPath, newName);
|
||||
if (newPath is null)
|
||||
{
|
||||
return BadRequest("The new name is not a valid library name.");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(currentPath))
|
||||
var currentPath = FileSystemHelper.GetChildPath(rootFolderPath, name);
|
||||
if (currentPath is null || !Directory.Exists(currentPath))
|
||||
{
|
||||
return NotFound("The media collection does not exist.");
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ public class MusicGenresController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
@@ -134,8 +140,7 @@ public class MusicGenresController : BaseJellyfinApiController
|
||||
|
||||
var result = _libraryManager.GetMusicGenres(query);
|
||||
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -521,7 +521,7 @@ public class PlaylistsController : BaseJellyfinApiController
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
var callingUserId = userId ?? User.GetUserId();
|
||||
var callingUserId = RequestHelpers.GetUserId(User, userId);
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
|
||||
@@ -306,11 +306,14 @@ public class SessionController : BaseJellyfinApiController
|
||||
[HttpPost("Sessions/{sessionId}/User/{userId}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult AddUserToSession(
|
||||
public async Task<ActionResult> AddUserToSession(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] Guid userId)
|
||||
{
|
||||
_sessionManager.AddAdditionalUser(sessionId, userId);
|
||||
_sessionManager.AddAdditionalUser(
|
||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||
sessionId,
|
||||
userId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -324,11 +327,14 @@ public class SessionController : BaseJellyfinApiController
|
||||
[HttpDelete("Sessions/{sessionId}/User/{userId}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult RemoveUserFromSession(
|
||||
public async Task<ActionResult> RemoveUserFromSession(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] Guid userId)
|
||||
{
|
||||
_sessionManager.RemoveAdditionalUser(sessionId, userId);
|
||||
_sessionManager.RemoveAdditionalUser(
|
||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||
sessionId,
|
||||
userId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -352,12 +358,13 @@ public class SessionController : BaseJellyfinApiController
|
||||
[FromQuery] bool supportsMediaControl = false,
|
||||
[FromQuery] bool supportsPersistentIdentifier = true)
|
||||
{
|
||||
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
id = currentSessionId;
|
||||
}
|
||||
|
||||
_sessionManager.ReportCapabilities(id, new ClientCapabilities
|
||||
_sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities
|
||||
{
|
||||
PlayableMediaTypes = playableMediaTypes,
|
||||
SupportedCommands = supportedCommands,
|
||||
@@ -381,12 +388,13 @@ public class SessionController : BaseJellyfinApiController
|
||||
[FromQuery] string? id,
|
||||
[FromBody, Required] ClientCapabilitiesDto capabilities)
|
||||
{
|
||||
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
id = currentSessionId;
|
||||
}
|
||||
|
||||
_sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
|
||||
_sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities());
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -405,9 +413,9 @@ public class SessionController : BaseJellyfinApiController
|
||||
[FromQuery] string? sessionId,
|
||||
[FromQuery, Required] string? itemId)
|
||||
{
|
||||
string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
|
||||
_sessionManager.ReportNowViewingItem(session, itemId);
|
||||
_sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,12 @@ public class StudiosController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
@@ -126,8 +132,7 @@ public class StudiosController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var result = _libraryManager.GetStudios(query);
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -156,7 +156,6 @@ public static class RequestHelpers
|
||||
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result,
|
||||
DtoOptions dtoOptions,
|
||||
IDtoService dtoService,
|
||||
bool includeItemTypes,
|
||||
User? user)
|
||||
{
|
||||
var dtos = result.Items.Select(i =>
|
||||
@@ -164,7 +163,7 @@ public static class RequestHelpers
|
||||
var (baseItem, counts) = i;
|
||||
var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes)
|
||||
if (counts is not null)
|
||||
{
|
||||
dto.ChildCount = counts.ItemCount;
|
||||
dto.ProgramCount = counts.ProgramCount;
|
||||
@@ -175,6 +174,7 @@ public static class RequestHelpers
|
||||
dto.AlbumCount = counts.AlbumCount;
|
||||
dto.SongCount = counts.SongCount;
|
||||
dto.ArtistCount = counts.ArtistCount;
|
||||
dto.MusicVideoCount = counts.MusicVideoCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
|
||||
@@ -246,14 +246,20 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
if (filter.IncludeItemTypes.Length > 0)
|
||||
var page = query.AsEnumerable().Where(e => e is not null).ToList();
|
||||
|
||||
if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes);
|
||||
var pageCleanNames = page
|
||||
.Where(e => !string.IsNullOrEmpty(e.CleanName))
|
||||
.Select(e => e.CleanName!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames);
|
||||
result.Items =
|
||||
[
|
||||
.. query
|
||||
.AsEnumerable()
|
||||
.Where(e => e is not null)
|
||||
.. page
|
||||
.Select(e =>
|
||||
{
|
||||
var item = DeserializeBaseItem(e, filter.SkipDeserialization);
|
||||
@@ -268,9 +274,7 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
result.Items =
|
||||
[
|
||||
.. query
|
||||
.AsEnumerable()
|
||||
.Where(e => e != null)
|
||||
.. page
|
||||
.Select(e => DeserializeBaseItem(e, filter.SkipDeserialization))
|
||||
.Where(item => item != null)
|
||||
.Select(item => (item!, (ItemCounts?)null))
|
||||
@@ -281,14 +285,22 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
|
||||
private Dictionary<string, ItemCounts> BuildItemCountsByCleanName(
|
||||
Database.Implementations.JellyfinDbContext context,
|
||||
JellyfinDbContext context,
|
||||
InternalItemsQuery filter,
|
||||
IReadOnlyList<ItemValueType> itemValueTypes)
|
||||
IReadOnlyList<ItemValueType> itemValueTypes,
|
||||
IReadOnlyList<string> cleanNames)
|
||||
{
|
||||
var typeSubQuery = new InternalItemsQuery(filter.User)
|
||||
var countsByCleanName = new Dictionary<string, ItemCounts>();
|
||||
if (cleanNames.Count == 0)
|
||||
{
|
||||
return countsByCleanName;
|
||||
}
|
||||
|
||||
// The counts describe everything the value is attached to, not only the types the list was
|
||||
// filtered down to.
|
||||
var scopeQuery = new InternalItemsQuery(filter.User)
|
||||
{
|
||||
ExcludeItemTypes = filter.ExcludeItemTypes,
|
||||
IncludeItemTypes = filter.IncludeItemTypes,
|
||||
MediaTypes = filter.MediaTypes,
|
||||
AncestorIds = filter.AncestorIds,
|
||||
ExcludeItemIds = filter.ExcludeItemIds,
|
||||
@@ -298,33 +310,51 @@ public sealed partial class BaseItemRepository
|
||||
IsPlayed = filter.IsPlayed
|
||||
};
|
||||
|
||||
var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
|
||||
.Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
|
||||
var scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery);
|
||||
var valueLinks = context.ItemValuesMap
|
||||
.AsNoTracking()
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
|
||||
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
|
||||
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
|
||||
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
|
||||
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
|
||||
var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo];
|
||||
var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram];
|
||||
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
|
||||
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
|
||||
var itemIds = itemCountQuery.Select(e => e.Id);
|
||||
|
||||
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
|
||||
// Instead, start from ItemValueMaps and join with BaseItems.
|
||||
var rawCounts = context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.Where(ivm => itemIds.Contains(ivm.ItemId))
|
||||
var rawCounts = valueLinks
|
||||
.Join(
|
||||
context.BaseItems,
|
||||
scopedItems,
|
||||
ivm => ivm.ItemId,
|
||||
e => e.Id,
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
|
||||
.GroupBy(x => new { x.CleanName, x.Type })
|
||||
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
|
||||
.AsEnumerable();
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId })
|
||||
.GroupBy(x => new { x.CleanName, x.Type, x.SeriesId })
|
||||
.Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() })
|
||||
.ToList();
|
||||
|
||||
// Only studios and genres pass down from a series to its episodes; an artist credit does not.
|
||||
var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre);
|
||||
var episodeCounts = inheritsToEpisodes
|
||||
? BuildEpisodeCountsByCleanName(
|
||||
scopedItems,
|
||||
valueLinks,
|
||||
rawCounts
|
||||
.Where(x => x.Type == episodeTypeName)
|
||||
.Select(x => (x.CleanName, x.SeriesId, x.Count))
|
||||
.ToList(),
|
||||
seriesTypeName,
|
||||
episodeTypeName)
|
||||
: rawCounts
|
||||
.Where(x => x.Type == episodeTypeName)
|
||||
.GroupBy(x => x.CleanName)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
|
||||
|
||||
var countsByCleanName = new Dictionary<string, ItemCounts>();
|
||||
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
|
||||
{
|
||||
var counts = new ItemCounts();
|
||||
@@ -334,10 +364,6 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
counts.SeriesCount += row.Count;
|
||||
}
|
||||
else if (row.Type == episodeTypeName)
|
||||
{
|
||||
counts.EpisodeCount += row.Count;
|
||||
}
|
||||
else if (row.Type == movieTypeName)
|
||||
{
|
||||
counts.MovieCount += row.Count;
|
||||
@@ -350,6 +376,14 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
counts.ArtistCount += row.Count;
|
||||
}
|
||||
else if (row.Type == musicVideoTypeName)
|
||||
{
|
||||
counts.MusicVideoCount += row.Count;
|
||||
}
|
||||
else if (row.Type == programTypeName)
|
||||
{
|
||||
counts.ProgramCount += row.Count;
|
||||
}
|
||||
else if (row.Type == audioTypeName)
|
||||
{
|
||||
counts.SongCount += row.Count;
|
||||
@@ -360,9 +394,72 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
}
|
||||
|
||||
// Episodes are counted separately: the value is usually only written on the series.
|
||||
counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key);
|
||||
counts.ItemCount = counts.TotalItemCount();
|
||||
countsByCleanName[group.Key] = counts;
|
||||
}
|
||||
|
||||
// A value carried by nothing but the episodes below a tagged series has no row of its own.
|
||||
foreach (var (cleanName, episodeCount) in episodeCounts)
|
||||
{
|
||||
if (!countsByCleanName.ContainsKey(cleanName))
|
||||
{
|
||||
countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount };
|
||||
}
|
||||
}
|
||||
|
||||
return countsByCleanName;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> BuildEpisodeCountsByCleanName(
|
||||
IQueryable<BaseItemEntity> scopedItems,
|
||||
IQueryable<ItemValueMap> valueLinks,
|
||||
IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes,
|
||||
string seriesTypeName,
|
||||
string episodeTypeName)
|
||||
{
|
||||
// Resolved in steps rather than as one union: each of these drives off an index, while the
|
||||
// single-statement form leaves SQLite free to scan every episode in the library instead.
|
||||
var taggedSeries = valueLinks
|
||||
.Join(
|
||||
scopedItems.Where(e => e.Type == seriesTypeName),
|
||||
ivm => ivm.ItemId,
|
||||
e => e.Id,
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id })
|
||||
.ToList();
|
||||
|
||||
var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray();
|
||||
var episodesPerSeries = seriesIds.Length == 0
|
||||
? []
|
||||
: scopedItems
|
||||
.Where(e => e.Type == episodeTypeName && e.SeriesId != null)
|
||||
.WhereOneOrMany(seriesIds, e => e.SeriesId!.Value)
|
||||
.GroupBy(e => e.SeriesId!.Value)
|
||||
.Select(g => new { SeriesId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.SeriesId, x => x.Count);
|
||||
|
||||
var episodeCounts = new Dictionary<string, int>();
|
||||
var seriesByCleanName = new Dictionary<string, HashSet<Guid>>();
|
||||
foreach (var group in taggedSeries.GroupBy(x => x.CleanName))
|
||||
{
|
||||
var series = group.Select(x => x.SeriesId).ToHashSet();
|
||||
seriesByCleanName[group.Key] = series;
|
||||
episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id));
|
||||
}
|
||||
|
||||
foreach (var (cleanName, seriesId, count) in taggedEpisodes)
|
||||
{
|
||||
if (seriesId is not null
|
||||
&& seriesByCleanName.TryGetValue(cleanName, out var series)
|
||||
&& series.Contains(seriesId.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count;
|
||||
}
|
||||
|
||||
return episodeCounts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,6 +1091,12 @@ public sealed partial class BaseItemRepository
|
||||
baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter));
|
||||
}
|
||||
|
||||
if (filter.DescendantOfId.HasValue)
|
||||
{
|
||||
var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value);
|
||||
baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.LinkedChildAncestorIds.Length > 0)
|
||||
{
|
||||
// Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids.
|
||||
|
||||
@@ -249,11 +249,51 @@ public class ItemCountService : IItemCountService
|
||||
}
|
||||
}
|
||||
|
||||
if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Episode)
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Series))
|
||||
{
|
||||
var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
|
||||
totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
|
||||
result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
|
||||
}
|
||||
|
||||
result.ItemCount = totalCount;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private int CountEpisodesOfTaggedSeries(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> taggedItems,
|
||||
InternalItemsQuery accessFilter,
|
||||
out int unrelatedEpisodeCount)
|
||||
{
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
|
||||
|
||||
var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id);
|
||||
unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName
|
||||
&& (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value)));
|
||||
|
||||
// Materialised so the episode count drives off IX_BaseItems_SeriesId.
|
||||
var seriesIds = taggedItems
|
||||
.Where(e => e.Type == seriesTypeName)
|
||||
.Select(e => e.Id)
|
||||
.ToArray();
|
||||
|
||||
if (seriesIds.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var episodes = context.BaseItems.AsNoTracking()
|
||||
.Where(e => e.Type == episodeTypeName && e.SeriesId != null)
|
||||
.WhereOneOrMany(seriesIds, e => e.SeriesId!.Value);
|
||||
|
||||
return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count();
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
|
||||
=> context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
|
||||
|
||||
@@ -319,7 +359,7 @@ public class ItemCountService : IItemCountService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parentIds);
|
||||
|
||||
@@ -332,20 +372,32 @@ public class ItemCountService : IItemCountService
|
||||
|
||||
var parentIdsArray = parentIds.ToArray();
|
||||
|
||||
var includeVirtual = user is null || user.DisplayMissingEpisodes;
|
||||
|
||||
var hierarchicalCounts = dbContext.BaseItems
|
||||
.Where(b => b.ParentId.HasValue)
|
||||
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
|
||||
.GroupBy(b => b.ParentId!.Value)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.ParentId, x => x.Count);
|
||||
|
||||
// An episode is a child of its season even when it is not stored under one: with a flat
|
||||
// structure ParentId points at the series, so counting by ParentId alone leaves the season
|
||||
// empty and counts its episodes towards the series instead.
|
||||
var seasonCounts = dbContext.BaseItems
|
||||
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
|
||||
.GroupBy(b => b.SeasonId!.Value)
|
||||
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.SeasonId, x => x.Count);
|
||||
|
||||
var linkedCounts = dbContext.LinkedChildren
|
||||
.WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
|
||||
.GroupBy(lc => lc.ParentId)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.ParentId, x => x.Count);
|
||||
|
||||
var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
|
||||
var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual);
|
||||
|
||||
var result = new Dictionary<Guid, int>();
|
||||
foreach (var parentId in parentIds)
|
||||
@@ -356,7 +408,8 @@ public class ItemCountService : IItemCountService
|
||||
continue;
|
||||
}
|
||||
|
||||
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
|
||||
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0)
|
||||
+ seasonCounts.GetValueOrDefault(parentId, 0);
|
||||
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
|
||||
|
||||
result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount;
|
||||
@@ -365,7 +418,7 @@ public class ItemCountService : IItemCountService
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
|
||||
private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual)
|
||||
{
|
||||
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
|
||||
.Where(group => group.Value.Count > 1)
|
||||
@@ -380,10 +433,16 @@ public class ItemCountService : IItemCountService
|
||||
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
|
||||
var children = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.ParentId.HasValue)
|
||||
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
|
||||
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
|
||||
.ToArray()
|
||||
.Concat(dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
|
||||
.Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
|
||||
.ToArray())
|
||||
.GroupBy(b => b.ParentId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
|
||||
@@ -22,6 +22,11 @@ namespace Jellyfin.Server.Migrations.Routines;
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
{
|
||||
private const int ParseProgressLogStep = 25_000;
|
||||
private const int FileCheckProgressLogStep = 10_000;
|
||||
private const int ResolveProgressLogStep = 10_000;
|
||||
private const int DeleteProgressLogStep = 25;
|
||||
|
||||
private readonly ILogger<MigrateLinkedChildren> _logger;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
@@ -85,7 +90,6 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
var droppedChildren = 0;
|
||||
var linkedChildrenToAdd = new List<LinkedChildEntity>();
|
||||
var processedCount = 0;
|
||||
const int progressLogStep = 1000;
|
||||
var totalItems = itemsWithData.Count;
|
||||
|
||||
foreach (var item in itemsWithData)
|
||||
@@ -95,7 +99,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processedCount > 0 && processedCount % progressLogStep == 0)
|
||||
if (processedCount > 0 && processedCount % ParseProgressLogStep == 0)
|
||||
{
|
||||
_logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems);
|
||||
}
|
||||
@@ -311,11 +315,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
|
||||
|
||||
var itemsToDelete = wrongTypeChildIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
var deleted = ResolveAndDeleteItems(wrongTypeChildIds, "wrong-type alternate version items");
|
||||
|
||||
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted);
|
||||
}
|
||||
@@ -342,11 +342,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count);
|
||||
|
||||
var itemsToDelete = orphanedVersionIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
var deleted = ResolveAndDeleteItems(orphanedVersionIds, "orphaned alternate version BaseItems");
|
||||
|
||||
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
|
||||
}
|
||||
@@ -371,11 +367,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
|
||||
|
||||
var itemsToDelete = orphanedIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
var deleted = ResolveAndDeleteItems(orphanedIds, "items from deleted libraries");
|
||||
|
||||
_logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
|
||||
}
|
||||
@@ -427,8 +419,23 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
var skippedUnrootedItems = 0;
|
||||
|
||||
var staleIds = new List<Guid>();
|
||||
var checkedCount = 0;
|
||||
_logger.LogInformation("Checking {Total} items for missing files.", itemsWithPaths.Count);
|
||||
|
||||
foreach (var item in itemsWithPaths)
|
||||
{
|
||||
// A miss on offline storage can block for the mount timeout, so report while scanning.
|
||||
if (checkedCount > 0 && checkedCount % FileCheckProgressLogStep == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Checking for missing files: {Checked}/{Total} items, {Stale} stale so far.",
|
||||
checkedCount,
|
||||
itemsWithPaths.Count,
|
||||
staleIds.Count);
|
||||
}
|
||||
|
||||
checkedCount++;
|
||||
|
||||
// Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
|
||||
var path = _appHost.ExpandVirtualPath(item.Path!);
|
||||
|
||||
@@ -482,16 +489,47 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
|
||||
|
||||
var itemsToDelete = staleIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
var deleted = ResolveAndDeleteItems(staleIds, "items with missing files");
|
||||
|
||||
_logger.LogInformation("Removed {Count} stale items.", deleted);
|
||||
}
|
||||
|
||||
private int DeleteItems(IReadOnlyCollection<BaseItem> items)
|
||||
private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DeleteItems(ResolveItems(ids, description), description);
|
||||
}
|
||||
|
||||
private List<BaseItem> ResolveItems(IReadOnlyCollection<Guid> ids, string description)
|
||||
{
|
||||
// Each lookup is a separate repository read; cached ones are fast, so this only reports
|
||||
// once a set is large enough for the reads to add up to a noticeable stretch.
|
||||
var items = new List<BaseItem>(ids.Count);
|
||||
var processed = 0;
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (processed > 0 && processed % ResolveProgressLogStep == 0)
|
||||
{
|
||||
_logger.LogInformation("Loading {Description}: {Processed}/{Total} items", description, processed, ids.Count);
|
||||
}
|
||||
|
||||
processed++;
|
||||
|
||||
var item = _libraryManager.GetItemById(id);
|
||||
if (item is not null)
|
||||
{
|
||||
items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private int DeleteItems(IReadOnlyCollection<BaseItem> items, string description)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
@@ -500,8 +538,16 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
|
||||
var deleted = 0;
|
||||
var processed = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (processed > 0 && processed % DeleteProgressLogStep == 0)
|
||||
{
|
||||
_logger.LogInformation("Removing {Description}: {Processed}/{Total} items", description, processed, items.Count);
|
||||
}
|
||||
|
||||
processed++;
|
||||
|
||||
try
|
||||
{
|
||||
_libraryManager.DeleteItem(item, options);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Migrations.Stages;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Enables the local similarity providers on libraries that predate the similar items settings.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-08-31T10:00:00", nameof(EnableLocalSimilarityProviders), Stage = JellyfinMigrationStageTypes.AppInitialisation)]
|
||||
internal class EnableLocalSimilarityProviders : IAsyncMigrationRoutine
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EnableLocalSimilarityProviders"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="providerManager">The provider manager.</param>
|
||||
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public EnableLocalSimilarityProviders(
|
||||
ILibraryManager libraryManager,
|
||||
IProviderManager providerManager,
|
||||
IStartupLogger<EnableLocalSimilarityProviders> startupLogger,
|
||||
ILogger<EnableLocalSimilarityProviders> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_providerManager = providerManager;
|
||||
_logger = startupLogger.With(logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Libraries created before similar items became configurable have an empty provider list,
|
||||
// which the library editor renders as "everything unchecked" instead of falling back to the
|
||||
// defaults it uses for new libraries. Seed the local providers so they stay enabled.
|
||||
var localProvidersByType = GetLocalProvidersByItemType();
|
||||
if (localProvidersByType.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
foreach (var virtualFolder in _libraryManager.GetVirtualFolders(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
EnableLocalProviders(virtualFolder, localProvidersByType);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void EnableLocalProviders(VirtualFolderInfo virtualFolder, Dictionary<string, string[]> localProvidersByType)
|
||||
{
|
||||
var options = virtualFolder.LibraryOptions;
|
||||
if (options?.TypeOptions is null || options.TypeOptions.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Some virtual folders don't have a proper item id.
|
||||
if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
|
||||
if (collectionFolder is null)
|
||||
{
|
||||
_logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
foreach (var typeOptions in options.TypeOptions)
|
||||
{
|
||||
changed |= EnableLocalProviders(typeOptions, localProvidersByType, virtualFolder.Name);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
collectionFolder.UpdateLibraryOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
private bool EnableLocalProviders(TypeOptions typeOptions, Dictionary<string, string[]> localProvidersByType, string libraryName)
|
||||
{
|
||||
if (typeOptions.Type is null || !localProvidersByType.TryGetValue(typeOptions.Type, out var localProviders))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var enabled = typeOptions.SimilarItemProviders ?? [];
|
||||
var missing = localProviders.Where(name => !enabled.Contains(name, StringComparer.OrdinalIgnoreCase)).ToArray();
|
||||
if (missing.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Local providers rank ahead of remote ones, and the enabled list doubles as the
|
||||
// priority order when no explicit order was saved.
|
||||
typeOptions.SimilarItemProviders = [.. missing, .. enabled];
|
||||
if (typeOptions.SimilarItemProviderOrder is { Length: > 0 } order)
|
||||
{
|
||||
typeOptions.SimilarItemProviderOrder = [.. missing, .. order];
|
||||
}
|
||||
|
||||
_logger.LogInformation("Enabled local similarity providers {Providers} for '{ItemType}' in library '{LibraryName}'.", missing, typeOptions.Type, libraryName);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Dictionary<string, string[]> GetLocalProvidersByItemType()
|
||||
{
|
||||
var result = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var summary in _providerManager.GetAllMetadataPlugins())
|
||||
{
|
||||
var names = summary.Plugins
|
||||
.Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider)
|
||||
.Select(p => p.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (names.Length > 0)
|
||||
{
|
||||
result[summary.ItemType] = names;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implement
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -265,6 +267,10 @@ Global
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -297,6 +303,7 @@ Global
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
|
||||
|
||||
@@ -103,6 +103,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
|| SubtitleLanguages.Count > 0
|
||||
|| LinkedChildAncestorIds.Length > 0
|
||||
|| AncestorIds.Length > 0
|
||||
|| DescendantOfId.HasValue
|
||||
|| IsFavorite.HasValue
|
||||
|| IsFavoriteOrLiked.HasValue
|
||||
|| IsLiked.HasValue
|
||||
@@ -368,6 +369,13 @@ namespace MediaBrowser.Controller.Entities
|
||||
/// </summary>
|
||||
public Guid[] LinkedChildAncestorIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the id of a folder whose descendants the items must be part of.
|
||||
/// Unlike <see cref="AncestorIds"/> this also follows the linked children of BoxSets and
|
||||
/// Playlists, so it reaches the items below a linked folder (a Series' episodes, for example).
|
||||
/// </summary>
|
||||
public Guid? DescendantOfId { get; set; }
|
||||
|
||||
public Guid[] TopParentIds { get; set; }
|
||||
|
||||
public CollectionType?[] PresetViews { get; set; }
|
||||
|
||||
@@ -166,4 +166,40 @@ public static class FileSystemHelper
|
||||
|
||||
return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines a caller supplied name with a parent directory, making sure the name cannot escape that directory.
|
||||
/// </summary>
|
||||
/// <param name="parentPath">The directory the name has to resolve inside of.</param>
|
||||
/// <param name="name">The name of the child.</param>
|
||||
/// <returns>
|
||||
/// The full path of the child, or <c>null</c> if <paramref name="name"/> is not the name of a direct child
|
||||
/// of <paramref name="parentPath"/>.
|
||||
/// </returns>
|
||||
public static string? GetChildPath(string parentPath, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Contains('\0', StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rejects directory separators, and on Windows also volume separators, as those make the name more than a single segment.
|
||||
if (!string.Equals(Path.GetFileName(name), name, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(parentPath, name));
|
||||
var fullParentPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parentPath));
|
||||
|
||||
// Catches the remaining relative names, "." and "..", which are valid single segments.
|
||||
if (!string.Equals(Path.GetDirectoryName(fullPath), fullParentPath, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Windows strips trailing dots and spaces, so a name like "..." resolves to the parent directory itself
|
||||
// and a name like "Movies." to a different child. Reject anything normalization did not leave intact.
|
||||
return string.Equals(Path.GetFileName(fullPath), name, StringComparison.Ordinal) ? fullPath : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,9 +758,9 @@ namespace MediaBrowser.Controller.Library
|
||||
/// Returns the count of immediate children (non-recursive) for each parent.
|
||||
/// </summary>
|
||||
/// <param name="parentIds">The list of parent folder IDs.</param>
|
||||
/// <param name="userId">The user ID for access filtering.</param>
|
||||
/// <param name="user">The user the counts are for, or null to count without a user's preferences.</param>
|
||||
/// <returns>Dictionary mapping parent ID to child count.</returns>
|
||||
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
|
||||
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user);
|
||||
|
||||
/// <summary>
|
||||
/// Batch-fetches played and total counts for multiple folder items.
|
||||
|
||||
@@ -80,7 +80,7 @@ public interface IItemCountService
|
||||
/// Batch-fetches child counts for multiple parent folders.
|
||||
/// </summary>
|
||||
/// <param name="parentIds">The list of parent folder IDs.</param>
|
||||
/// <param name="userId">The user ID for access filtering.</param>
|
||||
/// <param name="user">The user the counts are for, or null to count without a user's preferences.</param>
|
||||
/// <returns>Dictionary mapping parent ID to child count.</returns>
|
||||
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
|
||||
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user);
|
||||
}
|
||||
|
||||
@@ -238,23 +238,26 @@ namespace MediaBrowser.Controller.Session
|
||||
/// <summary>
|
||||
/// Adds the additional user.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
void AddAdditionalUser(string sessionId, Guid userId);
|
||||
void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the additional user.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
void RemoveAdditionalUser(string sessionId, Guid userId);
|
||||
void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// Reports the now viewing item.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="itemId">The item identifier.</param>
|
||||
void ReportNowViewingItem(string sessionId, string itemId);
|
||||
void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the new session.
|
||||
@@ -268,9 +271,10 @@ namespace MediaBrowser.Controller.Session
|
||||
/// <summary>
|
||||
/// Reports the capabilities.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="capabilities">The capabilities.</param>
|
||||
void ReportCapabilities(string sessionId, ClientCapabilities capabilities);
|
||||
void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities);
|
||||
|
||||
/// <summary>
|
||||
/// Reports the transcoding information.
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<img id="listenBrainzLogo" alt="ListenBrainz" style="max-width:240px;display:block;margin:0 auto 1em;" />
|
||||
<h1>ListenBrainz</h1>
|
||||
<p>Get similar artist recommendations from ListenBrainz Labs.</p>
|
||||
<form class="configForm">
|
||||
@@ -18,12 +17,12 @@
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="algorithm">Similarity Algorithm</label>
|
||||
<select is="emby-select" id="algorithm" class="emby-select-withcolor">
|
||||
<option value="0" selected>~5 years / 1825 days (Recommended)</option>
|
||||
<option value="1">~5 years / 1800 days</option>
|
||||
<option value="2">~20 years / 7500 days</option>
|
||||
<option value="3">~20 years / 7500 days (high contribution)</option>
|
||||
<option value="4">~25 years / 9000 days</option>
|
||||
<option value="5">~75 days (recent)</option>
|
||||
<option value="SessionBased1825Days" selected>~5 years / 1825 days (Recommended)</option>
|
||||
<option value="SessionBased1800Days">~5 years / 1800 days</option>
|
||||
<option value="SessionBased7500Days">~20 years / 7500 days</option>
|
||||
<option value="SessionBased7500DaysHighContribution">~20 years / 7500 days (high contribution)</option>
|
||||
<option value="SessionBased9000Days">~25 years / 9000 days</option>
|
||||
<option value="SessionBased75Days">~75 days (recent)</option>
|
||||
</select>
|
||||
<div class="fieldDescription">The algorithm used for artist similarity calculation.</div>
|
||||
</div>
|
||||
@@ -52,13 +51,14 @@
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var ListenBrainzPluginConfig = {
|
||||
uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e"
|
||||
uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e",
|
||||
defaultAlgorithm: "SessionBased1825Days"
|
||||
};
|
||||
|
||||
document.querySelector('.configPage')
|
||||
.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
document.querySelector('#listenBrainzLogo').src = ApiClient.getUrl('web/ConfigurationPage', { name: 'ListenBrainzLogo' });
|
||||
|
||||
ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) {
|
||||
var labsServer = document.querySelector('#labsServer');
|
||||
labsServer.value = config.LabsServer;
|
||||
@@ -67,7 +67,13 @@
|
||||
cancelable: false
|
||||
}));
|
||||
|
||||
document.querySelector('#algorithm').value = config.Algorithm;
|
||||
// The API serialises the algorithm as its enum name, so an unknown value here
|
||||
// means a config written by an older build; fall back to the default.
|
||||
var algorithm = document.querySelector('#algorithm');
|
||||
algorithm.value = config.Algorithm;
|
||||
if (!algorithm.value) {
|
||||
algorithm.value = ListenBrainzPluginConfig.defaultAlgorithm;
|
||||
}
|
||||
|
||||
var rateLimit = document.querySelector('#rateLimit');
|
||||
rateLimit.value = config.RateLimit;
|
||||
@@ -93,7 +99,7 @@
|
||||
|
||||
ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) {
|
||||
config.LabsServer = document.querySelector('#labsServer').value;
|
||||
config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10);
|
||||
config.Algorithm = document.querySelector('#algorithm').value;
|
||||
config.RateLimit = document.querySelector('#rateLimit').value;
|
||||
config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10);
|
||||
|
||||
|
||||
@@ -128,6 +128,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching.
|
||||
/// </summary>
|
||||
public int SimilarItemsCacheDays { get; set; } = 7;
|
||||
public int SimilarItemsCacheDays { get; set; } = 90;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SkiaSharp;
|
||||
using Svg;
|
||||
using Svg.Skia;
|
||||
|
||||
namespace Jellyfin.Drawing.Skia;
|
||||
@@ -48,6 +49,13 @@ public class SkiaEncoder : IImageEncoder
|
||||
/// </summary>
|
||||
public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
|
||||
|
||||
static SkiaEncoder()
|
||||
{
|
||||
SvgDocument.ResolveExternalElements = ExternalType.None;
|
||||
SvgDocument.ResolveExternalImages = ExternalType.None;
|
||||
SvgDocument.ResolveExternalXmlEntites = ExternalType.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
|
||||
/// </summary>
|
||||
@@ -183,6 +191,12 @@ public class SkiaEncoder : IImageEncoder
|
||||
var extension = Path.GetExtension(path.AsSpan());
|
||||
if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!SvgSecurityValidator.IsSafe(path, out var reason))
|
||||
{
|
||||
_logger.LogError("Refusing to determine dimensions for SVG {FilePath}: {Reason}", path, reason);
|
||||
return default;
|
||||
}
|
||||
|
||||
using var svg = new SKSvg();
|
||||
try
|
||||
{
|
||||
@@ -445,6 +459,12 @@ public class SkiaEncoder : IImageEncoder
|
||||
throw new FileNotFoundException("File not found", path);
|
||||
}
|
||||
|
||||
if (!SvgSecurityValidator.IsSafe(path, out var reason))
|
||||
{
|
||||
_logger.LogError("Refusing to render SVG {FilePath}: {Reason}", path, reason);
|
||||
return null;
|
||||
}
|
||||
|
||||
using var svg = SKSvg.CreateFromFile(path);
|
||||
if (svg.Drawable is null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
|
||||
[assembly: InternalsVisibleTo("Jellyfin.Drawing.Skia.Tests")]
|
||||
|
||||
namespace Jellyfin.Drawing.Skia;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an SVG document does not reference external resources before it is rasterized.
|
||||
/// </summary>
|
||||
internal static class SvgSecurityValidator
|
||||
{
|
||||
// Guards against a chain of nested data:image/svg+xml payloads.
|
||||
private const int MaxDataUriDepth = 4;
|
||||
|
||||
// Upper bound for a decompressed svgz payload carried inside a data URI, to guard against decompression bombs.
|
||||
private const int MaxDecompressedBytes = 16 * 1024 * 1024;
|
||||
|
||||
private const int DecompressBufferSize = 81920;
|
||||
|
||||
private static readonly XmlReaderSettings _scanSettings = new()
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Parse,
|
||||
XmlResolver = null,
|
||||
MaxCharactersFromEntities = 1024 * 1024,
|
||||
IgnoreComments = true,
|
||||
IgnoreProcessingInstructions = true,
|
||||
IgnoreWhitespace = true,
|
||||
CloseInput = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the SVG at the given path is safe to rasterize, i.e. contains no references
|
||||
/// to external resources.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the SVG file.</param>
|
||||
/// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
|
||||
/// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
|
||||
public static bool IsSafe(string path, [NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
reason = Validate(stream, 0);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
reason = "Unable to read the file for validation: " + ex.Message;
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
reason = "Unable to read the file for validation: " + ex.Message;
|
||||
}
|
||||
|
||||
return reason is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the SVG in the given stream is safe to rasterize.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream containing the SVG document.</param>
|
||||
/// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
|
||||
/// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
|
||||
public static bool IsSafe(Stream stream, [NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
reason = Validate(stream, 0);
|
||||
return reason is null;
|
||||
}
|
||||
|
||||
private static string? Validate(Stream stream, int depth)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = XmlReader.Create(stream, _scanSettings);
|
||||
while (reader.Read())
|
||||
{
|
||||
switch (reader.NodeType)
|
||||
{
|
||||
case XmlNodeType.DocumentType:
|
||||
{
|
||||
var subset = reader.Value;
|
||||
if (!string.IsNullOrEmpty(subset)
|
||||
&& (subset.Contains("SYSTEM", StringComparison.OrdinalIgnoreCase)
|
||||
|| subset.Contains("PUBLIC", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "The document declares an external DTD entity";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case XmlNodeType.Element when reader.HasAttributes:
|
||||
{
|
||||
for (var i = 0; i < reader.AttributeCount; i++)
|
||||
{
|
||||
reader.MoveToAttribute(i);
|
||||
var isHref = reader.LocalName.Equals("href", StringComparison.OrdinalIgnoreCase);
|
||||
var reason = isHref
|
||||
? ValidateReference(reader.Value, depth, "href")
|
||||
: ValidateCss(reader.Value, depth);
|
||||
if (reason is not null)
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
reader.MoveToElement();
|
||||
break;
|
||||
}
|
||||
|
||||
case XmlNodeType.Text:
|
||||
case XmlNodeType.CDATA:
|
||||
{
|
||||
var reason = ValidateCss(reader.Value, depth);
|
||||
if (reason is not null)
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
// Malformed markup, a forbidden DTD construct or an unresolved external entity: refuse to render.
|
||||
return "The document could not be safely parsed: " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateReference(ReadOnlySpan<char> value, int depth, string context)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.IsEmpty || trimmed[0] == '#')
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValidateDataUri(trimmed, depth, context);
|
||||
}
|
||||
|
||||
return "An external resource is referenced via " + context;
|
||||
}
|
||||
|
||||
private static string? ValidateDataUri(ReadOnlySpan<char> dataUri, int depth, string context)
|
||||
{
|
||||
// "data:[<mediatype>][;base64],<payload>" (mirrors Svg.Model's data URI parsing).
|
||||
var comma = dataUri.IndexOf(',');
|
||||
if (comma < 0)
|
||||
{
|
||||
return "A malformed data URI is referenced via " + context;
|
||||
}
|
||||
|
||||
var header = dataUri[5..comma];
|
||||
var firstSeparator = header.IndexOf(';');
|
||||
var mediaType = (firstSeparator < 0 ? header : header[..firstSeparator]).Trim();
|
||||
|
||||
// Only "image/svg+xml" is re-parsed as SVG by the renderer; any other type is treated as raster data.
|
||||
if (!mediaType.Contains('/') || !mediaType.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (depth >= MaxDataUriDepth)
|
||||
{
|
||||
return "Nested data URIs exceed the allowed depth";
|
||||
}
|
||||
|
||||
var lastSeparator = header.LastIndexOf(';');
|
||||
var isBase64 = lastSeparator >= 0
|
||||
&& header[(lastSeparator + 1)..].Trim().Equals("base64", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var payload = dataUri[(comma + 1)..].Trim();
|
||||
byte[]? buffer = null;
|
||||
try
|
||||
{
|
||||
int length;
|
||||
if (isBase64)
|
||||
{
|
||||
buffer = ArrayPool<byte>.Shared.Rent((payload.Length / 4 * 3) + 3);
|
||||
if (!Convert.TryFromBase64Chars(payload, buffer, out length))
|
||||
{
|
||||
return "An undecodable data URI is referenced via " + context;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var unescaped = Uri.UnescapeDataString(payload.ToString());
|
||||
buffer = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(unescaped.Length));
|
||||
length = Encoding.UTF8.GetBytes(unescaped, buffer);
|
||||
}
|
||||
|
||||
if (length > 2 && buffer[0] == 0x1F && buffer[1] == 0x8B)
|
||||
{
|
||||
using var decompressed = Decompress(buffer, length);
|
||||
return Validate(decompressed, depth + 1);
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream(buffer, 0, length, false);
|
||||
return Validate(stream, depth + 1);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return "An undecodable data URI is referenced via " + context + ": " + ex.Message;
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return "An invalid compressed data URI is referenced via " + context + ": " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (buffer is not null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static MemoryStream Decompress(byte[] compressed, int length)
|
||||
{
|
||||
using var input = new MemoryStream(compressed, 0, length, false);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
var output = new MemoryStream();
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(DecompressBufferSize);
|
||||
try
|
||||
{
|
||||
var total = 0;
|
||||
int read;
|
||||
while ((read = gzip.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
total += read;
|
||||
if (total > MaxDecompressedBytes)
|
||||
{
|
||||
throw new InvalidDataException("Compressed data URI exceeds the allowed size");
|
||||
}
|
||||
|
||||
output.Write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
output.Dispose();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
|
||||
private static string? ValidateCss(ReadOnlySpan<char> value, int depth)
|
||||
{
|
||||
if (value.IsEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
while (true)
|
||||
{
|
||||
var found = value[index..].IndexOf("url(", StringComparison.OrdinalIgnoreCase);
|
||||
if (found < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var start = index + found + 4;
|
||||
var close = value[start..].IndexOf(')');
|
||||
if (close < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var target = value.Slice(start, close).Trim();
|
||||
target = target.Trim('\'');
|
||||
target = target.Trim('"').Trim();
|
||||
var reason = ValidateReference(target, depth, "url()");
|
||||
if (reason is not null)
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
|
||||
index = start + close + 1;
|
||||
if (index >= value.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the bare "@import '...';" form (the "@import url(...)" form is covered above).
|
||||
index = 0;
|
||||
while (true)
|
||||
{
|
||||
var found = value[index..].IndexOf("@import", StringComparison.OrdinalIgnoreCase);
|
||||
if (found < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var rest = value[(index + found + 7)..];
|
||||
var quote = rest.IndexOfAny('\'', '"');
|
||||
if (quote >= 0)
|
||||
{
|
||||
var afterQuote = rest[(quote + 1)..];
|
||||
var end = afterQuote.IndexOfAny('\'', '"');
|
||||
if (end >= 0)
|
||||
{
|
||||
var reason = ValidateReference(afterQuote[..end], depth, "@import");
|
||||
if (reason is not null)
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
index = index + found + 7;
|
||||
if (index >= value.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Controller.Tests.IO;
|
||||
|
||||
public class FileSystemHelperTests
|
||||
{
|
||||
private static readonly string _parentPath = Path.Combine(Path.GetTempPath(), "jellyfin-test", "root", "default");
|
||||
|
||||
[Theory]
|
||||
[InlineData("Movies")]
|
||||
[InlineData("My Movies")]
|
||||
[InlineData("..2")]
|
||||
[InlineData("a.b")]
|
||||
public void GetChildPath_ValidName_ReturnsPathInsideParent(string name)
|
||||
{
|
||||
var path = FileSystemHelper.GetChildPath(_parentPath, name);
|
||||
|
||||
Assert.Equal(Path.Combine(_parentPath, name), path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(".")]
|
||||
[InlineData("..")]
|
||||
[InlineData("../..")]
|
||||
[InlineData("../../etc")]
|
||||
[InlineData("Movies/../..")]
|
||||
[InlineData("/var/lib/jellyfin/data")]
|
||||
[InlineData("sub/folder")]
|
||||
[InlineData("with\0null")]
|
||||
public void GetChildPath_EscapingName_ReturnsNull(string name)
|
||||
{
|
||||
Assert.Null(FileSystemHelper.GetChildPath(_parentPath, name));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("..\\..")]
|
||||
[InlineData("sub\\folder")]
|
||||
[InlineData("C:\\Windows")]
|
||||
public void GetChildPath_WindowsSeparator_DoesNotEscapeParent(string name)
|
||||
{
|
||||
var path = FileSystemHelper.GetChildPath(_parentPath, name);
|
||||
|
||||
// On Windows these are rejected outright, on other platforms a backslash is a legal file name character.
|
||||
Assert.True(path is null || string.Equals(Path.GetDirectoryName(path), _parentPath, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("...")]
|
||||
[InlineData("Movies.")]
|
||||
[InlineData("Movies ")]
|
||||
public void GetChildPath_TrailingDotOrSpace_RejectedOnWindows(string name)
|
||||
{
|
||||
var path = FileSystemHelper.GetChildPath(_parentPath, name);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Windows trims trailing dots and spaces, so the name would resolve to the parent or to a different child.
|
||||
Assert.Null(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(Path.Combine(_parentPath, name), path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildPath_ParentWithTrailingSeparator_ReturnsPathInsideParent()
|
||||
{
|
||||
var path = FileSystemHelper.GetChildPath(_parentPath + Path.DirectorySeparatorChar, "Movies");
|
||||
|
||||
Assert.Equal(Path.Combine(_parentPath, "Movies"), path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||
<PropertyGroup>
|
||||
<ProjectGuid>{E24A279C-9A37-419A-8F9C-853C11FBE753}</ProjectGuid>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Drawing.Skia.Tests;
|
||||
|
||||
public static class SvgSecurityValidatorTests
|
||||
{
|
||||
public static TheoryData<string> ExternalReferenceSvgs => new()
|
||||
{
|
||||
// SSRF via <image> (xlink:href and plain href)
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='http://169.254.169.254/latest/meta-data/' width='16' height='16'/></svg>",
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><image href='https://example.invalid/a.png' width='16' height='16'/></svg>",
|
||||
// Local file disclosure
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///etc/passwd' width='16' height='16'/></svg>",
|
||||
// Memory exhaustion DoS
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///dev/urandom' width='16' height='16'/></svg>",
|
||||
// <use> external reference
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><use xlink:href='http://example.invalid/c.svg#a'/></svg>",
|
||||
// CSS url() external reference in an attribute
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' style=\"fill:url(http://example.invalid/d.svg#g)\"/></svg>",
|
||||
// @import in a style block
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><style>@import 'http://example.invalid/e.css';</style><rect width='16' height='16'/></svg>",
|
||||
// Relative path traversal (resolves against the document location -> local file read)
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='../../../../etc/hosts' width='16' height='16'/></svg>",
|
||||
// XXE via external entity
|
||||
"<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&xxe;</text></svg>",
|
||||
// Entity-expansion (billion laughs) denial of service
|
||||
"<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY a 'aaaaaaaaaa'><!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'><!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'><!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'><!ENTITY e '&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;'><!ENTITY f '&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&f;</text></svg>",
|
||||
// Nested SVG in a base64 data: URI whose inner document references an external resource
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jz48aW1hZ2UgeGxpbms6aHJlZj0naHR0cDovL2V4YW1wbGUuaW52YWxpZC9uZXN0ZWQucG5nJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jy8+PC9zdmc+' width='16' height='16'/></svg>",
|
||||
// Nested SVG in a URL-encoded (non-base64) data: URI referencing an external resource
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20xmlns%3Axlink%3D%27http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%27%3E%3Cimage%20xlink%3Ahref%3D%27file%3A%2F%2F%2Fetc%2Fpasswd%27%2F%3E%3C%2Fsvg%3E' width='16' height='16'/></svg>",
|
||||
// Nested gzip-compressed (svgz) data: URI whose inner document references an external resource
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/23OwQrDIBAE0F/x5s217aWK8V+E2N2laiWRKP36Nin0lNvAPIZx64Zi5FTWSVJr1QL03lW/qdeCcNVaw1fIH7EjcXmewYsxBo5Wis5zo0nepaDISG2P3nEOGMVBLC3x8V+JI+SaouKyhcQz4FvVgucz4N1+x38AdK4P3LYAAAA=' width='16' height='16'/></svg>",
|
||||
};
|
||||
|
||||
public static TheoryData<string> SafeSvgs => new()
|
||||
{
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='red'/></svg>",
|
||||
// Same-document fragment references are allowed
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><defs><linearGradient id='g'/></defs><rect width='16' height='16' fill='url(#g)'/><use xlink:href='#g'/></svg>",
|
||||
// Inline data URIs are allowed
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' width='16' height='16'/></svg>",
|
||||
// A DOCTYPE without external entities is allowed
|
||||
"<?xml version='1.0'?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>",
|
||||
// An internal general entity with no external reference is allowed (and is expanded by the renderer)
|
||||
"<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY col 'red'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='&col;'/></svg>",
|
||||
// A nested data:image/svg+xml payload that is itself self-contained is allowed
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc4JyBoZWlnaHQ9JzgnPjxyZWN0IHdpZHRoPSc4JyBoZWlnaHQ9JzgnIGZpbGw9J2JsdWUnLz48L3N2Zz4=' width='16' height='16'/></svg>",
|
||||
// A self-contained gzip-compressed (svgz) data: URI is allowed
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/22Muw6AIAwAf6VbN0p0MQb4GBWBBB+Bav18ZXe75C5n6h3g2fJeLUbmcyQSESW9OkqgTmtNX4EgaeFocUCIPoXIDZ0pfuZfBWvK2eKUL4/kTHu4F2NB6oFrAAAA' width='16' height='16'/></svg>",
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ExternalReferenceSvgs))]
|
||||
public static void IsSafe_ExternalReference_ReturnsFalse(string svg)
|
||||
{
|
||||
var path = WriteTemp(svg);
|
||||
try
|
||||
{
|
||||
Assert.False(SvgSecurityValidator.IsSafe(path, out var reason));
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SafeSvgs))]
|
||||
public static void IsSafe_NoExternalReference_ReturnsTrue(string svg)
|
||||
{
|
||||
var path = WriteTemp(svg);
|
||||
try
|
||||
{
|
||||
Assert.True(SvgSecurityValidator.IsSafe(path, out var reason));
|
||||
Assert.Null(reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void IsSafe_MissingFile_ReturnsFalse()
|
||||
{
|
||||
Assert.False(SvgSecurityValidator.IsSafe(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Path.GetRandomFileName() + ".svg"), out var reason));
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
private static string WriteTemp(string svg)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".svg");
|
||||
File.WriteAllText(path, svg);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -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<User?>()))
|
||||
.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
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Xunit;
|
||||
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a
|
||||
/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a
|
||||
/// linked folder are only reachable by following the link and then the ancestor chain.
|
||||
/// </summary>
|
||||
public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture
|
||||
{
|
||||
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
|
||||
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
|
||||
private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
|
||||
private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season";
|
||||
private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
|
||||
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
|
||||
|
||||
private readonly BaseItemRepository _repository;
|
||||
|
||||
private readonly Guid _library = Guid.NewGuid();
|
||||
private readonly Guid _collection = Guid.NewGuid();
|
||||
private readonly Guid _series = Guid.NewGuid();
|
||||
private readonly Guid _season = Guid.NewGuid();
|
||||
private readonly Guid _episode = Guid.NewGuid();
|
||||
|
||||
// A movie the collection links directly, so the direct-child case is covered alongside the nested one.
|
||||
private readonly Guid _collectionMovie = Guid.NewGuid();
|
||||
|
||||
// In the same library but outside the collection, as the control the assertions are read against.
|
||||
private readonly Guid _otherSeries = Guid.NewGuid();
|
||||
private readonly Guid _otherEpisode = Guid.NewGuid();
|
||||
|
||||
public BaseItemRepositoryDescendantFilterTests()
|
||||
{
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
Seed(ctx);
|
||||
}
|
||||
|
||||
_repository = CreateBaseItemRepository(new ItemTypeLookup());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescendantOfId_ReachesEpisodesOfALinkedSeries()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery
|
||||
{
|
||||
DescendantOfId = _collection,
|
||||
IncludeItemTypes = [BaseItemKind.Episode]
|
||||
});
|
||||
|
||||
Assert.Equal([_episode], ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescendantOfId_ReturnsEveryLevelBelowTheCollection()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescendantOfId_KeepsDirectlyLinkedChildren()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery
|
||||
{
|
||||
DescendantOfId = _collection,
|
||||
IncludeItemTypes = [BaseItemKind.Movie]
|
||||
});
|
||||
|
||||
Assert.Equal([_collectionMovie], ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() });
|
||||
|
||||
Assert.Empty(ids);
|
||||
}
|
||||
|
||||
private void Seed(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" });
|
||||
|
||||
// AncestorIds is a closure: production writes one row per ancestor, not just the parent.
|
||||
AddAncestors(context, _series, _library);
|
||||
AddAncestors(context, _season, _series, _library);
|
||||
AddAncestors(context, _episode, _season, _series, _library);
|
||||
AddAncestors(context, _collectionMovie, _library);
|
||||
AddAncestors(context, _otherSeries, _library);
|
||||
AddAncestors(context, _otherEpisode, _otherSeries, _library);
|
||||
|
||||
AddLink(context, _series, 0);
|
||||
AddLink(context, _collectionMovie, 1);
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
|
||||
{
|
||||
foreach (var ancestorId in ancestorIds)
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = ancestorId,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder)
|
||||
{
|
||||
context.LinkedChildren.Add(new LinkedChildEntity
|
||||
{
|
||||
ParentId = _collection,
|
||||
ChildId = childId,
|
||||
ChildType = LinkedChildType.Manual,
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,78 @@ public sealed class ItemCountServiceTests : IDisposable
|
||||
Assert.Equal(2, result[seriesB]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason()
|
||||
{
|
||||
var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false);
|
||||
|
||||
var result = _service.GetChildCountBatch([seriesId, seasonId], null);
|
||||
|
||||
Assert.Equal(2, result[seasonId]);
|
||||
|
||||
// The series holds the season, not the episodes: counting those here would double them up.
|
||||
Assert.Equal(1, result[seriesId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce()
|
||||
{
|
||||
var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false);
|
||||
|
||||
var result = _service.GetChildCountBatch([seriesId, seasonId], null);
|
||||
|
||||
Assert.Equal(2, result[seasonId]);
|
||||
Assert.Equal(1, result[seriesId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem()
|
||||
{
|
||||
var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
|
||||
var user = new User("count-test", "provider", "reset");
|
||||
|
||||
user.DisplayMissingEpisodes = true;
|
||||
Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]);
|
||||
|
||||
// Nothing this user can open, so nothing to report.
|
||||
user.DisplayMissingEpisodes = false;
|
||||
Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_NoUser_CountsMissingEpisodes()
|
||||
{
|
||||
var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
|
||||
|
||||
Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]);
|
||||
}
|
||||
|
||||
private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes)
|
||||
{
|
||||
var seriesId = Guid.NewGuid();
|
||||
var seasonId = Guid.NewGuid();
|
||||
|
||||
using var context = CreateDbContext();
|
||||
context.BaseItems.Add(CreateItem(seriesId));
|
||||
context.BaseItems.Add(CreateItem(seasonId, seriesId));
|
||||
|
||||
// Flat: the episodes sit in the series folder, so ParentId points at the series and only
|
||||
// SeasonId ties them to the season they belong to.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId);
|
||||
episode.Type = "MediaBrowser.Controller.Entities.TV.Episode";
|
||||
episode.IsFolder = false;
|
||||
episode.IsVirtualItem = virtualEpisodes;
|
||||
episode.SeasonId = seasonId;
|
||||
context.BaseItems.Add(episode);
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
return (seriesId, seasonId);
|
||||
}
|
||||
|
||||
private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
|
||||
{
|
||||
var user = new User("count-test", "provider", "reset");
|
||||
|
||||
@@ -62,6 +62,36 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
Assert.Equal(expectedId, actualId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/media/Show/Season 01 [anidbid=11111]", "AniDB", "11111")]
|
||||
[InlineData("/media/Show/Season 01 [anidbid-11111]", "AniDB", "11111")]
|
||||
[InlineData("/media/Show/Season 02 [anilistid=22222]", "AniList", "22222")]
|
||||
[InlineData("/media/Show/Season 02 (anilistid=22222)", "AniList", "22222")]
|
||||
[InlineData("/media/Show/Season 03 [anisearchid=33333]", "AniSearch", "33333")]
|
||||
public void Resolve_SeasonFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId)
|
||||
{
|
||||
var series = new Series { Path = "/media/Show" };
|
||||
|
||||
var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
|
||||
Mock.Of<IServerApplicationPaths>(),
|
||||
null)
|
||||
{
|
||||
Parent = series,
|
||||
LibraryOptions = new LibraryOptions(),
|
||||
FileInfo = new FileSystemMetadata
|
||||
{
|
||||
FullName = path,
|
||||
IsDirectory = true
|
||||
}
|
||||
};
|
||||
|
||||
var season = _resolver.Resolve(args);
|
||||
|
||||
Assert.NotNull(season);
|
||||
Assert.True(season.TryGetProviderId(providerKey, out var actualId));
|
||||
Assert.Equal(expectedId, actualId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll()
|
||||
{
|
||||
@@ -140,6 +170,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _));
|
||||
Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _));
|
||||
Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _));
|
||||
Assert.False(season.TryGetProviderId("AniDB", out _));
|
||||
Assert.False(season.TryGetProviderId("AniList", out _));
|
||||
Assert.False(season.TryGetProviderId("AniSearch", out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
@@ -8,7 +11,9 @@ using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
@@ -108,4 +113,136 @@ public class SessionManagerTests
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageCommand_Should_ThrowSecurityException_WhenControllingAnotherUsersSession()
|
||||
{
|
||||
var victim = new User("victim", "default", "default");
|
||||
var attacker = new User("attacker", "default", "default");
|
||||
await using var sessionManager = CreateSessionManager(victim, attacker);
|
||||
|
||||
var victimSession = await LogSessionActivity(sessionManager, victim);
|
||||
var attackerSession = await LogSessionActivity(sessionManager, attacker);
|
||||
|
||||
await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand(
|
||||
attackerSession.Id,
|
||||
victimSession.Id,
|
||||
new MessageCommand { Header = "Custom Message", Text = "test exploit!" },
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageCommand_Should_Succeed_WhenAllowedToControlOtherUsers()
|
||||
{
|
||||
var victim = new User("victim", "default", "default");
|
||||
var attacker = new User("controller", "default", "default");
|
||||
attacker.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true);
|
||||
await using var sessionManager = CreateSessionManager(victim, attacker);
|
||||
|
||||
var victimSession = await LogSessionActivity(sessionManager, victim);
|
||||
var controllingSession = await LogSessionActivity(sessionManager, attacker);
|
||||
|
||||
await sessionManager.SendMessageCommand(
|
||||
controllingSession.Id,
|
||||
victimSession.Id,
|
||||
new MessageCommand { Header = "Custom Message", Text = "hello" },
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogSessionActivity_Should_NotReuseAnotherUsersSession()
|
||||
{
|
||||
var victim = new User("victim", "default", "default");
|
||||
var attacker = new User("attacker", "default", "default");
|
||||
await using var sessionManager = CreateSessionManager(victim, attacker);
|
||||
|
||||
// Client name and device id are attacker controlled, so they must not identify a session on their own.
|
||||
var victimSession = await LogSessionActivity(sessionManager, victim);
|
||||
var attackerSession = await LogSessionActivity(sessionManager, attacker);
|
||||
|
||||
Assert.NotEqual(victimSession.Id, attackerSession.Id);
|
||||
Assert.Equal(victim.Id, victimSession.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAdditionalUser_Should_ThrowSecurityException_WhenAttachingAnotherUser()
|
||||
{
|
||||
var attacker = new User("attacker", "default", "default");
|
||||
var victim = new User("victim", "default", "default");
|
||||
await using var sessionManager = CreateSessionManager(victim, attacker);
|
||||
|
||||
var attackerSession = await LogSessionActivity(sessionManager, attacker);
|
||||
|
||||
Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAdditionalUser_Should_Succeed_WhenCallerIsAdministrator()
|
||||
{
|
||||
var admin = new User("admin", "default", "default");
|
||||
admin.SetPermission(PermissionKind.IsAdministrator, true);
|
||||
var guest = new User("guest", "default", "default");
|
||||
await using var sessionManager = CreateSessionManager(admin, guest);
|
||||
|
||||
var adminSession = await LogSessionActivity(sessionManager, admin);
|
||||
|
||||
sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id);
|
||||
|
||||
Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAdditionalUser_Should_ThrowSecurityException_WhenModifyingAnotherUsersSession()
|
||||
{
|
||||
var victim = new User("victim", "default", "default");
|
||||
var attacker = new User("attacker", "default", "default");
|
||||
await using var sessionManager = CreateSessionManager(victim, attacker);
|
||||
|
||||
var victimSession = await LogSessionActivity(sessionManager, victim);
|
||||
var attackerSession = await LogSessionActivity(sessionManager, attacker);
|
||||
|
||||
Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportCapabilities_Should_ThrowSecurityException_WhenReportingForAnotherUsersSession()
|
||||
{
|
||||
var victim = new User("victim", "default", "default");
|
||||
var attacker = new User("attacker", "default", "default");
|
||||
await using var sessionManager = CreateSessionManager(victim, attacker);
|
||||
|
||||
var victimSession = await LogSessionActivity(sessionManager, victim);
|
||||
var attackerSession = await LogSessionActivity(sessionManager, attacker);
|
||||
|
||||
Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities()));
|
||||
}
|
||||
|
||||
private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users)
|
||||
{
|
||||
var userManager = new Mock<IUserManager>();
|
||||
foreach (var user in users)
|
||||
{
|
||||
userManager.Setup(i => i.GetUserById(user.Id)).Returns(user);
|
||||
}
|
||||
|
||||
return new Emby.Server.Implementations.Session.SessionManager(
|
||||
NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
|
||||
Mock.Of<IEventManager>(),
|
||||
Mock.Of<IUserDataManager>(),
|
||||
Mock.Of<IServerConfigurationManager>(),
|
||||
Mock.Of<ILibraryManager>(),
|
||||
userManager.Object,
|
||||
Mock.Of<IMusicManager>(),
|
||||
Mock.Of<IDtoService>(),
|
||||
Mock.Of<IImageProcessor>(),
|
||||
Mock.Of<IServerApplicationHost>(),
|
||||
Mock.Of<IDeviceManager>(),
|
||||
Mock.Of<IMediaSourceManager>(),
|
||||
Mock.Of<IHostApplicationLifetime>());
|
||||
}
|
||||
|
||||
// All sessions are logged with the same client and device id on purpose, those values are taken
|
||||
// from the request headers and are not bound to the access token of the calling user.
|
||||
private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user)
|
||||
=> sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user);
|
||||
}
|
||||
|
||||
+52
@@ -114,6 +114,58 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Priority(1)]
|
||||
[InlineData("..")]
|
||||
[InlineData("../..")]
|
||||
[InlineData(".")]
|
||||
[InlineData("test/../..")]
|
||||
[InlineData("/var/lib/jellyfin/data")]
|
||||
public async Task DeleteLibrary_PathTraversal_NotFound(string name)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
|
||||
|
||||
using var response = await client.DeleteAsync($"Library/VirtualFolders?name={Uri.EscapeDataString(name)}", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Priority(1)]
|
||||
[InlineData("..")]
|
||||
[InlineData("../..")]
|
||||
[InlineData(".")]
|
||||
[InlineData("test/../..")]
|
||||
[InlineData("/var/lib/jellyfin/data")]
|
||||
public async Task RenameLibrary_PathTraversalNewName_BadRequest(string newName)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
$"Library/VirtualFolders/Name?name=test&newName={Uri.EscapeDataString(newName)}",
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Priority(1)]
|
||||
[InlineData("..")]
|
||||
[InlineData("../..")]
|
||||
[InlineData("/var/lib/jellyfin/data")]
|
||||
public async Task RenameLibrary_PathTraversalName_NotFound(string name)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
$"Library/VirtualFolders/Name?name={Uri.EscapeDataString(name)}&newName=renamed",
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Priority(1)]
|
||||
public async Task DeleteLibrary_Valid_Success()
|
||||
|
||||
Reference in New Issue
Block a user