Compare commits

..

45 Commits

Author SHA1 Message Date
Cody Robibero 4910aafa1a Merge pull request #17743 from Shadowghost/migration-logging
Add progress logging to data migrations
2026-08-31 17:49:14 -04:00
Cody Robibero 726ae93a92 Merge pull request #17750 from Shadowghost/refresh-people-ordering
Enforce reliable processing order in PeopleValidationTask
2026-08-31 17:48:21 -04:00
Cody Robibero 0cec5661e0 Merge pull request #17752 from Shadowghost/fix-linkedchildren-recursive
Fix recursive handling for LinkedChildren
2026-08-31 17:47:33 -04:00
Cody Robibero fe73b1a949 Merge pull request #17748 from Shadowghost/fix-default-config-12.x
Fix ListenBrainz settings and similar item defaults
2026-08-31 17:46:30 -04:00
Cody Robibero 80324b19fb Merge pull request #17724 from Shadowghost/fix-by-name-item-counts
Fix item counts on the by-name endpoints
2026-08-31 15:19:08 -04:00
Shadowghost 05844d60c1 Apply review suggestions, remove Logo on config page 2026-08-31 20:44:41 +02:00
Cody Robibero eafe5ba6ed Merge pull request #17729 from Shadowghost/enforce-acl-similar-items
Enforce permissions on similar items
2026-08-31 12:02:33 -04:00
Shadowghost 7ccce8e0e7 Fix recursive handling for LinkedChildren 2026-08-31 17:56:56 +02:00
Shadowghost 40ab2c4284 Enforce reliable processing order in PeopleValidationTask 2026-08-31 15:44:32 +02:00
Shadowghost d1dda7f6c5 Add progress logging to data migrations 2026-08-31 10:39:39 +02:00
Shadowghost ff36560575 Enable local similarity providers on libraries upgraded from 10.11 2026-08-31 08:37:57 +02:00
Shadowghost e9abaa519c Repair the blank algorithm select and missing logo in ListenBrainz plugin settings 2026-08-31 08:37:44 +02:00
Shadowghost 359e8069d0 Default the similar items TMDb cache to 90 days 2026-08-31 08:37:35 +02:00
Cody Robibero 7d6633ad1e Merge pull request #17744 from Shadowghost/fix-master
Fix formatting and test
2026-08-30 17:52:39 -04:00
Shadowghost 11adad0e67 Fix formatting and test 2026-08-30 22:29:36 +02:00
Cody Robibero 420d44f638 Merge commit from fork
Fix broken access control in session management
2026-08-30 14:40:25 -04:00
Cody Robibero 9fe5a53e47 Merge commit from fork
Secure library paths
2026-08-30 14:40:16 -04:00
Cody Robibero 2996f726c1 Merge commit from fork
Prevent SSRF, local file disclosure and DoS via external references in SVG rendering
2026-08-30 14:40:03 -04:00
Cody Robibero 37db4bda53 Merge commit from fork
Fix calling user for playlist items
2026-08-30 14:39:28 -04:00
Cody Robibero 0d78e44633 Merge pull request #17739 from Shadowghost/simplify-search-query
Filter search candidates by user access in a single query
2026-08-30 13:29:59 -04:00
Cody Robibero 4962e4ec33 Merge pull request #17735 from Shadowghost/fix-season-child-count
Count a season's episodes by the season they belong to
2026-08-30 13:29:13 -04:00
Shadowghost 07e97c0ee9 Filter search candidates by user access in a single query 2026-08-30 12:33:12 +02:00
krvi 24288e79a9 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-30 00:59:15 +00:00
Shadowghost 27d898e59e Count a season's episodes by the season they belong to 2026-08-29 21:39:14 +02:00
Shadowghost 95281a2205 Revert "Fix ParentId for episodes in virtual seasons"
This reverts commit 6da85a0aaa87bdd2c81ef2b1936a2e35c1478b76.
2026-08-29 21:36:49 +02:00
Cody Robibero 5a2f45ab4b Merge pull request #17731 from Shadowghost/anime-id-season-folder
Support anime provider ids on season folders
2026-08-29 09:57:26 -04:00
krvi fbb0f1afbc Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-28 23:35:45 +00:00
Shadowghost ea5f83328d Support anime provider ids on season folders 2026-08-28 15:48:17 +02:00
Shadowghost a45e66d43c Enforce permissions on similar items 2026-08-28 11:01:43 +02:00
Pavel Miniutka 6ad1e341b1 Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/be/
2026-08-28 07:44:55 +00:00
Pavel Miniutka b46e66627c Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/be/
2026-08-28 07:30:44 +00:00
Pavel Miniutka 7116d3bb72 Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/be/
2026-08-28 07:26:34 +00:00
Shadowghost 99f21f1662 Apply review suggestions 2026-08-28 07:21:37 +02:00
Cody Robibero 9126de26c0 Merge pull request #17719 from theguymadmax/fix-episodes-ParentId
Fix ParentId for episodes in virtual seasons
2026-08-27 19:10:43 -04:00
Cody Robibero 75df54611a Merge pull request #17718 from Shadowghost/fix-virtual-children
Fix children count on virtual items
2026-08-27 19:10:33 -04:00
Shadowghost ceeeaaab8e Fix By-Name item count handling 2026-08-27 10:19:36 +02:00
theguymadmax 6da85a0aaa Fix ParentId for episodes in virtual seasons 2026-08-26 21:51:31 -04:00
Shadowghost 2aad6047c8 Fix children count on virtual items 2026-08-26 18:57:50 +02:00
Dan Bishop 1cc490fb19 Translated using Weblate (English (United Kingdom))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/en_GB/
2026-08-26 13:52:43 +00:00
Shadowghost cefa78fc1d Prevent SSRF, local file disclosure and DoS via external references in SVG rendering 2026-08-25 11:04:24 +02:00
Shadowghost 0c560b22ce Secure library paths 2026-08-25 09:28:45 +02:00
Shadowghost 911ac3769c Fix GHSA-9x85-gx46-6522 2026-08-23 19:20:58 -04:00
Shadowghost 4de43d36dd Require session ownership for additional users, capabilities and viewing reports 2026-08-14 14:50:07 +02:00
Shadowghost b1e3cf1341 Key active sessions by user to prevent session takeover 2026-08-14 14:50:06 +02:00
Shadowghost dd7de41878 Enforce EnableRemoteControlOfOtherUsers on session control 2026-08-14 14:50:06 +02:00
43 changed files with 1890 additions and 210 deletions
@@ -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);
}
+14 -50
View File
@@ -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>
+7 -2
View File
@@ -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)
{
+18 -10
View File
@@ -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>
+2 -2
View File
@@ -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;
}
}
+7
View File
@@ -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;
}
}
+20
View File
@@ -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
@@ -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);
}
@@ -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()