Merge remote-tracking branch 'upstream/master' into security-path-traversal-fixes

# Conflicts:
#	Jellyfin.Api/Controllers/HlsSegmentController.cs
#	Jellyfin.Api/Controllers/PluginsController.cs
This commit is contained in:
Shadowghost
2026-07-21 07:14:47 +02:00
79 changed files with 1930 additions and 518 deletions
+1 -17
View File
@@ -1385,38 +1385,22 @@ namespace Emby.Server.Implementations.Dto
}
}
if (options.PreferEpisodeParentPoster)
if (options.GetImageLimit(ImageType.Primary) > 0)
{
var episodeSeason = episode.Season;
var seasonPrimaryTag = episodeSeason is not null
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
: null;
BaseItem? posterParent = null;
if (seasonPrimaryTag is not null)
{
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
dto.ParentPrimaryImageTag = seasonPrimaryTag;
posterParent = episodeSeason;
}
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
{
dto.ParentPrimaryImageItemId = episodeSeries.Id;
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
posterParent = episodeSeries;
}
if (posterParent is not null)
{
if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag))
{
// Only drop the episode's own primary blurhash; keep the poster parent's.
dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag);
}
dto.SeriesPrimaryImageTag = null;
dto.PrimaryImageAspectRatio = null;
AttachPrimaryImageAspectRatio(dto, posterParent);
}
}
@@ -31,6 +31,12 @@ namespace Emby.Server.Implementations.Images
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
var recursive = viewType != CollectionType.playlists;
if (viewType == CollectionType.music)
{
// Music albums usually don't have dedicated backdrops, so use artist instead
includeItemTypes = [BaseItemKind.MusicArtist];
}
return view.GetItemList(new InternalItemsQuery
{
CollapseBoxSetItems = false,
@@ -418,10 +418,10 @@ namespace Emby.Server.Implementations.Library
}
/// <summary>
/// Populates each source's own playback position for the user and, when the queried item is a
/// primary, moves the most recently played version to the front so that resuming without an
/// explicit source selection plays the version that was last watched. A directly queried
/// alternate version keeps its own source first.
/// When the queried item is a primary, moves the most recently played version to the front so
/// that resuming without an explicit source selection plays the version that was last watched.
/// A directly queried alternate version keeps its own source first. Per-user playback position
/// is not surfaced on the source itself; it is carried by each version's own UserData.
/// </summary>
/// <param name="item">The queried item.</param>
/// <param name="sources">The item's media sources.</param>
@@ -451,16 +451,6 @@ namespace Emby.Server.Implementations.Library
}
}
foreach (var source in sources)
{
if (source.Id is not null
&& dataBySourceId.TryGetValue(source.Id, out var data)
&& data.PlaybackPositionTicks > 0)
{
source.PlaybackPositionTicks = data.PlaybackPositionTicks;
}
}
// Reorder only for a resumable (in-progress) version;
// a completed version has no position to resume, so it must not be pulled to the front here.
var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
@@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library
throw new ArgumentException("String can't be empty.", nameof(attribute));
}
var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
// Must be at least 3 characters after the attribute =, ], any character,
// then we offset it by 1, because we want the index and not length.
var maxIndex = str.Length - attribute.Length - 2;
while (attributeIndex > -1 && attributeIndex < maxIndex)
// Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
// The code below only supports aliases for attributes in the form of "<alias>id".
ReadOnlySpan<char> shortAttr = attribute switch
{
var attributeEnd = attributeIndex + attribute.Length;
_ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
_ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
_ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
_ => ReadOnlySpan<char>.Empty
};
for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
{
// We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'.
var subStr = str[strIndex..];
int attributeEnd = 0;
if (shortAttr.Length > 0)
{
// If we are using an alias it should be shorter (and a prefix), so let's search for that.
attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
attributeEnd = attributeIndex + shortAttr.Length;
}
else
{
attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
attributeEnd = attributeIndex + attribute.Length;
}
// The next iteration should start at the end of the attribute we just found.
// If attributeIndex < 0, the loop will end and strIndex won't be used again.
strIndex += attributeEnd;
if (attributeIndex > 0)
{
var attributeOpener = str[attributeIndex - 1];
var attributeOpener = subStr[attributeIndex - 1];
var attributeCloser = attributeOpener switch
{
'[' => ']',
@@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library
'{' => '}',
_ => '\0'
};
if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
{
var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
// Must be at least 1 character before the closing bracket.
if (closingIndex > 1)
if (attributeCloser != '\0')
{
if (shortAttr.Length > 0
&& attributeEnd + 1 < subStr.Length
&& (subStr[attributeEnd] is 'i' or 'I')
&& (subStr[attributeEnd + 1] is 'd' or 'D'))
{
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
// We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
attributeEnd += 2;
}
// attributeEnd points at '='.
// We need at least 1 more character and the closing bracket after that.
if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-'))
{
var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser);
// Must be at least 1 character before the closing bracket.
if (closingIndex > 1)
{
var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim();
if (trimmed.Length > 0)
{
return trimmed.ToString();
}
}
}
}
}
str = str[attributeEnd..];
attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
}
// for imdbid we also accept pattern matching
@@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library
return match ? imdbId.ToString() : null;
}
// Allow tmdb as an alias for tmdbid
if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
{
var tmdbValue = str.GetAttributeValue("tmdb");
if (tmdbValue is not null)
{
return tmdbValue;
}
}
return null;
}
@@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
// We need to only look at the name of this actual item (not parents)
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
var tmdbid = justName.GetAttributeValue("tmdbid");
// The fallback filename is only used when the item isn't in a mixed folder
var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan());
// If not in a mixed folder and ID not found in folder path, check filename
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute)
{
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
}
var id = name.GetAttributeValue(attribute);
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
// If not in a mixed folder and ID not found in folder path, check filename
if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder)
{
id = fallbackName.GetAttributeValue(attribute);
}
return id;
}
if (!string.IsNullOrEmpty(item.Path))
{
@@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library
}
else
{
var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault();
var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
var userData = userDataRow is not null ? Map(userDataRow) : null;
if (userData is not null)
{
result[item.Id] = userData;
@@ -211,37 +212,32 @@ namespace Emby.Server.Implementations.Library
return result;
}
// Build a single query for all missing items
// Build a single query for all missing items. Fetch rows by item alone so rows kept
// under keys from older metadata resolve the same way as the in-memory path.
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
if (allKeys.Count > 0)
using var context = _repository.CreateDbContext();
var userDataArray = context.UserData
.AsNoTracking()
.Where(e => e.UserId.Equals(user.Id))
.WhereOneOrMany(allItemIds, e => e.ItemId)
.ToArray();
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
foreach (var (item, keys) in itemsNeedingQuery)
{
using var context = _repository.CreateDbContext();
var userDataArray = context.UserData
.AsNoTracking()
.Where(e => e.UserId.Equals(user.Id))
.WhereOneOrMany(allItemIds, e => e.ItemId)
.WhereOneOrMany(allKeys, e => e.CustomDataKey)
.ToArray();
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
foreach (var (item, keys) in itemsNeedingQuery)
UserItemData userData;
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
UserItemData userData;
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N"));
userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First());
}
else
{
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
}
result[item.Id] = userData;
var cacheKey = GetCacheKey(user.InternalId, item.Id);
_cache.AddOrUpdate(cacheKey, userData);
userData = Map(ResolveUserDataRow(item, itemUserData)!);
}
else
{
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
}
result[item.Id] = userData;
var cacheKey = GetCacheKey(user.InternalId, item.Id);
_cache.AddOrUpdate(cacheKey, userData);
}
return result;
@@ -291,8 +287,8 @@ namespace Emby.Server.Implementations.Library
{
using var dbContext = _repository.CreateDbContext();
withLocalAlternates = dbContext.LinkedChildren
.Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion
&& localProbeIds.Contains(lc.ParentId))
.Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion)
.WhereOneOrMany(localProbeIds, lc => lc.ParentId)
.Select(lc => lc.ParentId)
.Distinct()
.ToHashSet();
@@ -356,12 +352,40 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item)
{
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
return row is not null ? Map(row) : new UserItemData()
{
Key = item.GetUserDataKeys()[0],
};
}
/// <summary>
/// Picks the row matching the item's current user data keys, in key order, so rows left behind
/// under keys from older metadata don't take priority over the rows the write path updates.
/// </summary>
/// <param name="item">The item whose keys to match.</param>
/// <param name="rows">The candidate user data rows for a single user.</param>
/// <returns>The best matching row, or <c>null</c> when there are none.</returns>
private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows)
{
var candidates = rows?.ToList();
if (candidates is null || candidates.Count == 0)
{
return null;
}
foreach (var key in item.GetUserDataKeys())
{
var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal));
if (match is not null)
{
return match;
}
}
return candidates[0];
}
/// <inheritdoc />
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
=> GetUserDataDto(item, null, user, new DtoOptions());
@@ -1,7 +1,7 @@
{
"Artists": "Listafólk",
"Collections": "Søvn",
"Default": "Sjálvgildi",
"Default": "Forsett",
"External": "Ytri",
"Genres": "Greinar",
"AppDeviceValues": "App: {0}, Eind: {1}",
@@ -12,5 +12,41 @@
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
"LabelIpAddressValue": "IP atsetur: {0}"
"LabelIpAddressValue": "IP-atsetur: {0}",
"AuthenticationSucceededWithUserName": "{0} varð samgildur",
"HeaderFavoriteShows": "Yndisrøðir",
"HeaderLiveTV": "Beinleiðis sjónvarp",
"HearingImpaired": "Hoyrnarveik",
"Inherit": "Arvar",
"LabelRunningTimeValue": "Spælitíð: {0}",
"Latest": "Seinastu",
"LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}",
"NameInstallFailed": "{0} innlegging miseydnaðist",
"NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
"NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
"NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
"NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
"NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
"NotificationOptionUserLockedOut": "Brúkari útihýstur",
"Photos": "Ljósmyndir",
"PluginInstalledWithName": "{0} var innlagt",
"PluginUninstalledWithName": "{0} var strikað",
"PluginUpdatedWithName": "{0} varð dagført",
"Shows": "Røðir",
"SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
"TvShows": "Sjónvarpsrøðir",
"UserCreatedWithName": "Brúkari {0} er stovnaður",
"UserDeletedWithName": "Brúkari {0} er strikaður",
"UserDownloadingItemWithValues": "{0} niðurtekur {1}",
"UserLockedOutWithName": "Brúkari {0} er útihýstur",
"VersionNumber": "Útgáva {0}",
"TasksLibraryCategory": "Savn",
"TaskRefreshLibrary": "Skanna miðlasavn",
"TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.",
"TaskUpdatePlugins": "Dagfør ískoytisforrit",
"TaskRefreshChannels": "Endurinnles rásir",
"TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir",
"Movies": "Filmar",
"MixedContent": "Blandað innihald",
"Music": "Tónleikur"
}
@@ -107,5 +107,6 @@
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.",
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
"Original": "Original"
"Original": "Original",
"LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
}
@@ -106,5 +106,7 @@
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.",
"LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}",
"Original": "Upprunaleg",
"TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt."
"TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.",
"TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir",
"TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins."
}
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
"CleanupUserDataTask": "用戶資料清理工作",
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
"Original": "原作",
"LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
}
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask
{
FileName = _mediaEncoder.EncoderPath,
Arguments = args,
RedirectStandardOutput = false,
StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
},
})