Merge branch 'master' into fix/backup-skip-corrupt-keyframe-data

This commit is contained in:
Cody Robibero
2026-07-20 20:49:37 -04:00
committed by GitHub
72 changed files with 1850 additions and 512 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'
+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 @@
"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
},
})
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Api.Helpers;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations.Enums;
@@ -84,36 +84,9 @@ public class ActivityLogController : BaseJellyfinApiController
ItemId = itemId,
Username = username,
Severity = severity,
OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []),
OrderBy = RequestHelpers.GetOrderBy(sortBy ?? [], sortOrder ?? []),
};
return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false);
}
private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy(
IReadOnlyList<ActivityLogSortBy> sortBy,
IReadOnlyList<SortOrder> requestedSortOrder)
{
if (sortBy.Count == 0)
{
return [];
}
var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count];
var i = 0;
for (; i < requestedSortOrder.Count; i++)
{
result[i] = (sortBy[i], requestedSortOrder[i]);
}
// Add remaining elements with the first specified SortOrder
// or the default one if no SortOrders are specified
var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
for (; i < sortBy.Count; i++)
{
result[i] = (sortBy[i], order);
}
return result;
}
}
@@ -60,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController
public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
{
// TODO: Deprecate with new iOS app
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
var transcodePath = _serverConfigurationManager.GetTranscodePath();
file = Path.GetFullPath(Path.Combine(transcodePath, file));
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
if (file is null)
{
return BadRequest("Invalid segment.");
}
@@ -86,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
{
var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()));
var transcodePath = _serverConfigurationManager.GetTranscodePath();
file = Path.GetFullPath(Path.Combine(transcodePath, file));
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)
|| Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())));
if (file is null
|| !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Invalid segment.");
}
@@ -140,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController
[FromRoute, Required] string segmentId,
[FromRoute, Required] string segmentContainer)
{
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
if (file is null)
{
return BadRequest("Invalid segment.");
}
var normalizedPlaylistId = playlistId;
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
// Add . to start of segment container for future use.
segmentContainer = segmentContainer.Insert(0, ".");
@@ -161,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController
var pathExtension = Path.GetExtension(path);
if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
|| string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
&& path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase))
&& path.Contains(playlistId, StringComparison.OrdinalIgnoreCase))
{
playlistPath = path;
break;
@@ -173,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController
: GetFileResult(file, playlistPath);
}
private string? ValidateTranscodePath(string filename)
{
var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath()));
var file = Path.GetFullPath(filename, transcodePath);
// Require a separator after the transcode path so a sibling like "<transcodePath>-evil" can't pass.
if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return file;
}
private ActionResult GetFileResult(string path, string playlistPath)
{
var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
@@ -302,6 +302,8 @@ public class ItemUpdateController : BaseJellyfinApiController
{
foreach (var season in rseries.Children.OfType<Season>())
{
season.SeriesName = rseries.Name;
if (!season.LockedFields.Contains(MetadataField.OfficialRating))
{
season.OfficialRating = request.OfficialRating;
@@ -319,6 +321,8 @@ public class ItemUpdateController : BaseJellyfinApiController
foreach (var ep in season.Children.OfType<Episode>())
{
ep.SeriesName = rseries.Name;
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
{
ep.OfficialRating = request.OfficialRating;
@@ -226,10 +226,13 @@ public class PluginsController : BaseJellyfinApiController
return NotFound();
}
if (!string.IsNullOrEmpty(plugin.Manifest.ImagePath))
string? imagePath = plugin.Manifest.ImagePath;
if (!string.IsNullOrWhiteSpace(imagePath))
{
var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
if (!System.IO.File.Exists(imagePath))
var pluginPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(plugin.Path));
imagePath = Path.GetFullPath(imagePath, pluginPath);
// Require a separator after the plugin path so a sibling like "<pluginPath>-evil" can't pass.
if (imagePath.StartsWith(pluginPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false)
{
return NotFound();
}
@@ -551,8 +551,6 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
dtoOptions.PreferEpisodeParentPoster = true;
var list = _userViewManager.GetLatestItems(
new LatestItemsQuery
{
+1 -1
View File
@@ -24,7 +24,7 @@ public static class DtoExtensions
case CollectionType.tvshows:
return [BaseItemKind.Series];
case CollectionType.music:
return [BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist];
return [BaseItemKind.MusicAlbum];
case CollectionType.musicvideos:
return [BaseItemKind.MusicVideo];
case CollectionType.books:
+4 -4
View File
@@ -5,7 +5,6 @@ using System.Security.Claims;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
@@ -31,15 +30,16 @@ public static class RequestHelpers
/// </summary>
/// <param name="sortBy">Sort By. Comma delimited string.</param>
/// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param>
/// <typeparam name="TSortBy">The type of the sort by field.</typeparam>
/// <returns>Order By.</returns>
public static (ItemSortBy, SortOrder)[] GetOrderBy(IReadOnlyList<ItemSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
public static (TSortBy, SortOrder)[] GetOrderBy<TSortBy>(IReadOnlyList<TSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
{
if (sortBy.Count == 0)
{
return Array.Empty<(ItemSortBy, SortOrder)>();
return Array.Empty<(TSortBy, SortOrder)>();
}
var result = new (ItemSortBy, SortOrder)[sortBy.Count];
var result = new (TSortBy, SortOrder)[sortBy.Count];
var i = 0;
// Add elements which have a SortOrder specified
for (; i < requestedSortOrder.Count; i++)
@@ -557,8 +557,13 @@ public sealed partial class BaseItemRepository
: baseQuery.Where(e => inProgressIds.Contains(e.Id));
// When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
// Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
// which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
// the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Where(s => s.Id != e.Id
&& inProgressIds.Contains(s.Id)
&& (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
@@ -616,6 +616,12 @@ namespace Jellyfin.Server.Implementations.Users
.SetProperty(f => f.LastActivityDate, date)
.SetProperty(f => f.LastLoginDate, date))
.ConfigureAwait(false);
// ExecuteUpdateAsync bypasses the change tracker, so keep the
// returned entity in sync. Otherwise SessionManager.LogSessionActivity
// saves this (stale) entity in full and reverts LastLoginDate.
user.LastActivityDate = date;
user.LastLoginDate = date;
}
await dbContext.Users
@@ -883,8 +889,20 @@ namespace Jellyfin.Server.Implementations.Users
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Remove(user.ProfileImage);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
// Remove the tracked profile image loaded from the database instead of the
// detached instance on the passed in user. That instance can carry a stale,
// never-persisted (temporary) key, which makes EF Core throw when it is marked
// for deletion, leaving the profile image impossible to clear or replace.
var dbUser = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == user.Id)
.ConfigureAwait(false);
if (dbUser?.ProfileImage is not null)
{
dbContext.Remove(dbUser.ProfileImage);
dbUser.ProfileImage = null;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
user.ProfileImage = null;
@@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions
c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API");
c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css");
c.RoutePrefix = "api-docs/swagger";
c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }""");
})
.UseReDoc(c =>
{
@@ -81,13 +81,6 @@ namespace MediaBrowser.Controller.Dto
/// </summary>
public bool AddCurrentProgram { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
/// image, falling back to the series') should replace the episode's own (16:9) primary image.
/// Used by views that render episodes as poster cards, e.g. "Latest".
/// </summary>
public bool PreferEpisodeParentPoster { get; set; }
/// <summary>
/// Gets a value indicating whether the specified field is populated.
/// </summary>
@@ -1,12 +1,13 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
/// <summary>
/// Interface for items that have special features.
/// </summary>
public interface IHasSpecialFeatures
{
/// <summary>
@@ -1,11 +1,15 @@
#pragma warning disable CS1591
using System;
namespace MediaBrowser.Controller.Entities
{
/// <summary>
/// Interface for items that have a start date.
/// </summary>
public interface IHasStartDate
{
/// <summary>
/// Gets or sets the start date.
/// </summary>
DateTime StartDate { get; set; }
}
}
@@ -1,19 +1,28 @@
#pragma warning disable CS1591
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
/// <summary>
/// Marker interface.
/// Marker interface for items that represent a name, like a genre or a studio.
/// </summary>
public interface IItemByName
{
/// <summary>
/// Gets the items tagged with this name.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The tagged items.</returns>
IReadOnlyList<BaseItem> GetTaggedItems(InternalItemsQuery query);
}
/// <summary>
/// Interface for by-name items that can also be accessed as a regular library item.
/// </summary>
public interface IHasDualAccess : IItemByName
{
/// <summary>
/// Gets a value indicating whether the item is accessed by name.
/// </summary>
bool IsAccessedByName { get; }
}
}
@@ -1,7 +1,8 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Entities
{
/// <summary>
/// Interface for items that can be placeholders.
/// </summary>
public interface ISupportsPlaceHolders
{
/// <summary>
+14 -2
View File
@@ -1,11 +1,23 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Entities
{
/// <summary>
/// The source of an item.
/// </summary>
public enum SourceType
{
/// <summary>
/// The item comes from a library.
/// </summary>
Library = 0,
/// <summary>
/// The item comes from a channel.
/// </summary>
Channel = 1,
/// <summary>
/// The item comes from live TV.
/// </summary>
LiveTV = 2
}
}
@@ -4054,7 +4054,7 @@ namespace MediaBrowser.Controller.MediaEncoding
mainFilters.Add(swDeintFilter);
}
var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p";
var outFormat = doCuTonemap ? "p010le" : "yuv420p";
var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH);
// sw scale
mainFilters.Add(swScaleFilter);
@@ -6,6 +6,7 @@ using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -184,8 +185,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
{ "libavdevice", new Version(58, 13) },
{ "libavfilter", new Version(7, 110) },
{ "libswscale", new Version(5, 9) },
{ "libswresample", new Version(3, 9) },
{ "libpostproc", new Version(55, 9) }
{ "libswresample", new Version(3, 9) }
};
private readonly ILogger _logger;
@@ -645,7 +645,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false,
RedirectStandardInput = redirectStandardIn,
StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
}
})
@@ -7,6 +7,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
@@ -528,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
UseShellExecute = false,
// Must consume both or ffmpeg may hang due to deadlocks.
StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
FileName = _ffprobePath,
@@ -926,6 +928,25 @@ namespace MediaBrowser.MediaEncoding.Encoder
throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters.");
}
// Normalize invalid PTS from containers for non keyframe only mode
if (!enableKeyFrameOnlyExtraction)
{
var fpsFilterIndex = filterParam.IndexOf("fps=", StringComparison.Ordinal);
if (fpsFilterIndex >= 0)
{
var inputFrameRate = (imageStream.ReferenceFrameRate.HasValue && imageStream.ReferenceFrameRate > 0)
? imageStream.ReferenceFrameRate.Value : 30;
var setPtsFilter = string.Create(CultureInfo.InvariantCulture, $"setpts=N/{inputFrameRate:F3}/TB,");
filterParam = filterParam.Insert(fpsFilterIndex, setPtsFilter);
}
else
{
throw new InvalidOperationException("EncodingHelper returned invalid filter parameters.");
}
}
try
{
return await ExtractVideoImagesOnIntervalInternal(
@@ -1,7 +1,7 @@
#pragma warning disable CS1591
using System.IO;
using MediaBrowser.Model.MediaInfo;
using Nikse.SubtitleEdit.Core.Common;
namespace MediaBrowser.MediaEncoding.Subtitles
{
@@ -12,8 +12,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
/// </summary>
/// <param name="stream">The stream.</param>
/// <param name="fileExtension">The file extension.</param>
/// <returns>SubtitleTrackInfo.</returns>
SubtitleTrackInfo Parse(Stream stream, string fileExtension);
/// <returns>The parsed subtitle.</returns>
Subtitle Parse(Stream stream, string fileExtension);
/// <summary>
/// Determines whether the file extension is supported by the parser.
@@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Jellyfin.Extensions;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Nikse.SubtitleEdit.Core.Common;
using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat;
@@ -30,7 +28,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
/// <inheritdoc />
public SubtitleTrackInfo Parse(Stream stream, string fileExtension)
public Subtitle Parse(Stream stream, string fileExtension)
{
var subtitle = new Subtitle();
var lines = stream.ReadAllLines().ToList();
@@ -76,21 +74,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
throw new ArgumentException("Unsupported format: " + fileExtension);
}
var trackInfo = new SubtitleTrackInfo();
int len = subtitle.Paragraphs.Count;
var trackEvents = new SubtitleTrackEvent[len];
for (int i = 0; i < len; i++)
{
var p = subtitle.Paragraphs[i];
trackEvents[i] = new SubtitleTrackEvent(p.Number.ToString(CultureInfo.InvariantCulture), p.Text)
{
StartPositionTicks = p.StartTime.TimeSpan.Ticks,
EndPositionTicks = p.EndTime.TimeSpan.Ticks
};
}
trackInfo.TrackEvents = trackEvents;
return trackInfo;
return subtitle;
}
/// <inheritdoc />
@@ -73,7 +73,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
_serverConfigurationManager = serverConfigurationManager;
}
private MemoryStream ConvertSubtitles(
internal MemoryStream ConvertSubtitles(
Stream stream,
SubtitleInfo inputInfo,
string outputFormat,
@@ -81,7 +81,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
long endTimeTicks,
bool preserveOriginalTimestamps)
{
var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path));
var subtitle = _subtitleParser.Parse(stream, inputInfo.Format);
FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
@@ -445,98 +445,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
encodingParam = " -sub_charenc " + encodingParam;
}
int exitCode;
var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
CreateNoWindow = true,
UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath,
Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
EnableRaisingEvents = true
})
{
_logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
try
{
process.Start();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
throw;
}
try
{
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode == -1)
{
failed = true;
if (File.Exists(outputPath))
{
try
{
_logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
}
}
}
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
failed = true;
try
{
_logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (FileNotFoundException)
{
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
}
}
if (failed)
{
_logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));
}
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
await ExtractSubtitlesForFile(
inputPath,
args,
[outputPath],
cancellationToken).ConfigureAwait(false);
WriteCacheMeta(outputPath, inputPath);
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
}
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
@@ -727,7 +644,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
"-i {0}",
"-y -i {0}",
inputPath);
foreach (var subtitleStream in subtitleStreams)
@@ -781,50 +698,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private async Task ExtractSubtitlesForFile(
string inputPath,
string args,
List<string> outputPaths,
IReadOnlyList<string> outputPaths,
CancellationToken cancellationToken)
{
int exitCode;
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
CreateNoWindow = true,
UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath,
Arguments = args,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
EnableRaisingEvents = true
})
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
try
{
process.Start();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
throw;
}
try
{
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false);
var failed = false;
@@ -884,6 +761,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (failed)
{
cancellationToken.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(ffmpegError))
{
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError);
}
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
}
@@ -941,16 +825,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles
ArgumentException.ThrowIfNullOrEmpty(outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
var processArgs = string.Format(
CultureInfo.InvariantCulture,
"-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
"-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
inputPath,
subtitleStreamIndex,
outputCodec,
outputPath);
await ExtractSubtitlesForFile(
inputPath,
processArgs,
[outputPath],
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output.
/// </summary>
/// <remarks>
/// stdin is redirected and closed, and <c>-nostdin</c> is prepended to the arguments, so ffmpeg can never
/// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM,
/// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer
/// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is
/// unused for subtitle extraction.
/// </remarks>
/// <param name="arguments">The ffmpeg command line arguments.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The ffmpeg exit code (-1 on timeout) and its captured stderr output.</returns>
private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken)
{
int exitCode;
var standardError = string.Empty;
using (var process = new Process
{
@@ -958,8 +864,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
FileName = _mediaEncoder.EncoderPath,
Arguments = processArgs,
Arguments = "-nostdin " + arguments,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
@@ -975,14 +883,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
throw;
}
// Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle.
process.StandardInput.Close();
// Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg.
var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes));
try
{
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
@@ -990,59 +905,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode == -1)
{
failed = true;
try
{
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
standardError = await standardErrorTask.ConfigureAwait(false);
}
catch (FileNotFoundException)
catch (OperationCanceledException)
{
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
}
}
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
failed = true;
try
{
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (FileNotFoundException)
{
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
// Reading ffmpeg output was cancelled; nothing more to capture.
}
}
if (failed)
{
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));
}
_logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
{
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
}
return (exitCode, standardError);
}
/// <summary>
@@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
// Must consume both stdout and stderr or deadlocks may occur
// RedirectStandardOutput = true,
StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
FileName = _mediaEncoder.EncoderPath,
@@ -55,11 +55,6 @@ namespace MediaBrowser.Model.Dto
public long? RunTimeTicks { get; set; }
/// <summary>
/// Gets or sets the playback position for this specific source.
/// </summary>
public long? PlaybackPositionTicks { get; set; }
public bool ReadAtNativeFramerate { get; set; }
public bool IgnoreDts { get; set; }
@@ -2,6 +2,7 @@ using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Xml;
using Jellyfin.Data.Enums;
@@ -17,7 +18,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
/// Methods used to pull metadata and other information from Open Packaging Format in XML objects.
/// </summary>
/// <typeparam name="TCategoryName">The type of category.</typeparam>
public class OpfReader<TCategoryName>
public partial class OpfReader<TCategoryName>
{
private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
@@ -42,6 +43,9 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
_namespaceManager.AddNamespace("opf", OpfNamespace);
}
[GeneratedRegex(@"(?<=\p{L})\.(?!\s|$)")]
private static partial Regex InitialsRegex();
/// <summary>
/// Checks for the existence of a cover image.
/// </summary>
@@ -229,11 +233,23 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
foreach (XmlElement creator in resultElement)
{
var creatorName = creator.InnerText;
var role = creator.GetAttribute("opf:role");
var person = new PersonInfo { Name = creatorName, Type = GetRole(role) };
var normalizedCreators = creator.InnerText
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(fullName =>
{
if (fullName.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is [var lastName, var firstName])
{
fullName = $"{firstName} {lastName}";
}
book.AddPerson(person);
return InitialsRegex().Replace(fullName, ". ");
});
foreach (var fullName in normalizedCreators)
{
book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) });
}
}
}
}
@@ -680,11 +680,18 @@ namespace MediaBrowser.Providers.Manager
return providers;
}
protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, ImageRefreshOptions options)
protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, MetadataRefreshOptions options)
{
// Get providers to refresh
var providers = allImageProviders.Where(i => i is not ILocalImageProvider);
// When identifying, run the provider the user picked first so the correct image is used.
if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName))
{
providers = providers
.OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1);
}
var dateLastImageRefresh = item.DateLastRefreshed;
// Run all if either of these flags are true
@@ -256,11 +256,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
series.Overview = seriesResult.Overview;
var studios = Enumerable.Empty<string>();
if (seriesResult.Networks is not null)
{
series.Studios = seriesResult.Networks.Select(i => i.Name).ToArray();
studios = studios.Concat(seriesResult.Networks.Select(i => i.Name).OfType<string>());
}
if (seriesResult.ProductionCompanies is not null)
{
studios = studios.Concat(seriesResult.ProductionCompanies.Select(i => i.Name).OfType<string>());
}
series.SetStudios(studios);
if (seriesResult.Genres is not null)
{
series.Genres = seriesResult.Genres.Select(i => i.Name).ToArray();
@@ -320,13 +329,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (seriesResult.Videos?.Results is not null)
{
foreach (var video in seriesResult.Videos.Results)
var trailers = new List<MediaUrl>();
var sortedVideos = seriesResult.Videos.Results
.OrderByDescending(video => string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase));
foreach (var video in sortedVideos)
{
if (TmdbUtils.IsTrailerType(video))
if (!TmdbUtils.IsTrailerType(video))
{
series.AddTrailerUrl("https://www.youtube.com/watch?v=" + video.Key);
continue;
}
trailers.Add(new MediaUrl
{
Url = string.Format(CultureInfo.InvariantCulture, "https://www.youtube.com/watch?v={0}", video.Key),
Name = video.Name
});
}
series.RemoteTrailers = trailers;
}
if (!string.IsNullOrEmpty(seriesResult.OriginalLanguage))
+9 -27
View File
@@ -7,34 +7,16 @@
<img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
<br/>
<br/>
<a href="https://github.com/jellyfin/jellyfin">
<img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/>
</a>
<a href="https://github.com/jellyfin/jellyfin/releases">
<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/>
</a>
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget">
<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/>
</a>
<a href="https://hub.docker.com/r/jellyfin/jellyfin">
<img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/>
</a>
<a href="https://github.com/jellyfin/jellyfin"><img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/></a>
<a href="https://github.com/jellyfin/jellyfin/releases"><img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/></a>
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"><img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/></a>
<a href="https://hub.docker.com/r/jellyfin/jellyfin"><img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/></a>
<br/>
<a href="https://opencollective.com/jellyfin">
<img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/>
</a>
<a href="https://features.jellyfin.org">
<img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/>
</a>
<a href="https://matrix.to/#/#jellyfinorg:matrix.org">
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/>
</a>
<a href="https://github.com/jellyfin/jellyfin/releases.atom">
<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
</a>
<a href="https://github.com/jellyfin/jellyfin/commits/master.atom">
<img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" />
</a>
<a href="https://opencollective.com/jellyfin"><img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/></a>
<a href="https://features.jellyfin.org"><img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/></a>
<a href="https://matrix.to/#/#jellyfinorg:matrix.org"><img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/></a>
<a href="https://github.com/jellyfin/jellyfin/releases.atom"><img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /></a>
<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"><img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /></a>
</p>
---
+18 -9
View File
@@ -7,6 +7,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using Jellyfin.LiveTv;
using Jellyfin.LiveTv.Configuration;
using Jellyfin.LiveTv.Listings;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -486,8 +487,13 @@ public class GuideManager : IGuideManager
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
item.TrySetProviderId(EtagKey, info.Etag);
}
else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey)))
{
// XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes,
// so an exact match means nothing relevant changed. Other providers stay on the
// field-by-field update path.
return (item, false, false);
}
if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
@@ -613,13 +619,9 @@ public class GuideManager : IGuideManager
forceUpdate |= UpdateImages(item, info);
if (isNew)
{
item.OnMetadataChanged();
return (item, true, false);
}
// Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and
// persist it on new items so they join the fast path on the next refresh
// instead of taking an extra full processing cycle.
var isUpdated = forceUpdate;
var etag = info.Etag;
if (string.IsNullOrWhiteSpace(etag))
@@ -632,6 +634,13 @@ public class GuideManager : IGuideManager
isUpdated = true;
}
if (isNew)
{
item.OnMetadataChanged();
return (item, true, false);
}
if (isUpdated)
{
item.OnMetadataChanged();
@@ -83,6 +83,7 @@ namespace Jellyfin.LiveTv.IO
CreateNoWindow = true,
UseShellExecute = false,
StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
@@ -491,6 +491,12 @@ namespace Jellyfin.LiveTv.Listings
var results = new List<ShowImagesDto>();
for (int i = 0; i < programIds.Count; i += BatchSize)
{
// The daily image limit may be surfaced mid-batch.
if (IsImageDailyLimitActive())
{
break;
}
var batch = programIds.Skip(i).Take(BatchSize);
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
@@ -511,6 +517,18 @@ namespace Jellyfin.LiveTv.Listings
entry.ProgramId,
entry.Code,
entry.Message);
// The image download limit can be reported per-entry inside an
// otherwise successful (HTTP 200) response when the limit is hit
// mid-batch. Back off so we stop requesting images until SD resets.
if (entry.Code is (int)SdErrorCode.MaxImageDownloads or (int)SdErrorCode.MaxImageDownloadsTrial)
{
_logger.LogError(
"Schedules Direct image download limit hit (code {Code}). Disabling image acquisition until SD reset.",
entry.Code);
SetImageLimitHit();
}
continue;
}
@@ -173,7 +173,29 @@ namespace Jellyfin.LiveTv.Listings
var reader = new XmlTvReader(path, GetLanguage(info));
return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken)
.Select(p => GetProgramInfo(p, info));
.Select(p => GetProgramInfoWithEtag(p, info));
}
private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info)
{
var programInfo = GetProgramInfo(program, info);
if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason))
{
programInfo.Etag = etag;
}
else
{
_logger.LogDebug(
"Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate} to {EndDate}: {Reason}. The program will be treated as updated on each guide refresh.",
programInfo.Id,
programInfo.ChannelId,
programInfo.StartDate,
programInfo.EndDate,
reason);
}
return programInfo;
}
private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
@@ -0,0 +1,184 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using MediaBrowser.Controller.LiveTv;
namespace Jellyfin.LiveTv.Listings
{
internal static class XmlTvProgramEtag
{
internal const string Prefix = "xmltv-sha256-v1:";
internal static bool IsXmlTvEtag(string? etag)
=> !string.IsNullOrWhiteSpace(etag)
&& etag.StartsWith(Prefix, StringComparison.Ordinal);
// Returns true only when the incoming etag is XMLTV-style AND equals the stored value.
// The IsXmlTvEtag gate keeps other providers (e.g. Schedules Direct) on the
// field-by-field update path even if their etag strings happen to match.
internal static bool MatchesStored(string? incomingEtag, string? storedEtag)
=> IsXmlTvEtag(incomingEtag)
&& string.Equals(incomingEtag, storedEtag, StringComparison.OrdinalIgnoreCase);
internal static bool TryCreate(ProgramInfo programInfo, out string? etag, out string? reason)
{
etag = null;
if (string.IsNullOrWhiteSpace(programInfo.Id))
{
reason = "program id is empty";
return false;
}
if (string.IsNullOrWhiteSpace(programInfo.ChannelId))
{
reason = "channel id is empty";
return false;
}
if (programInfo.StartDate == default)
{
reason = "start date is empty";
return false;
}
if (programInfo.EndDate == default)
{
reason = "end date is empty";
return false;
}
if (programInfo.EndDate <= programInfo.StartDate)
{
reason = "end date is not after start date";
return false;
}
var builder = new StringBuilder(1024);
// Keep this list aligned with the ProgramInfo fields consumed by GuideManager.
AppendValue(builder, "schema", "xmltv-programinfo-v1");
AppendValue(builder, nameof(programInfo.Id), programInfo.Id);
AppendValue(builder, nameof(programInfo.ChannelId), programInfo.ChannelId);
AppendValue(builder, nameof(programInfo.Name), programInfo.Name);
AppendValue(builder, nameof(programInfo.OfficialRating), programInfo.OfficialRating);
AppendValue(builder, nameof(programInfo.Overview), programInfo.Overview);
AppendValue(builder, nameof(programInfo.StartDate), programInfo.StartDate);
AppendValue(builder, nameof(programInfo.EndDate), programInfo.EndDate);
AppendList(builder, nameof(programInfo.Genres), programInfo.Genres);
AppendValue(builder, nameof(programInfo.OriginalAirDate), programInfo.OriginalAirDate);
AppendValue(builder, nameof(programInfo.IsHD), programInfo.IsHD);
AppendValue(builder, nameof(programInfo.Audio), programInfo.Audio?.ToString());
AppendValue(builder, nameof(programInfo.CommunityRating), programInfo.CommunityRating);
AppendValue(builder, nameof(programInfo.IsRepeat), programInfo.IsRepeat);
AppendValue(builder, nameof(programInfo.EpisodeTitle), programInfo.EpisodeTitle);
AppendValue(builder, nameof(programInfo.ImagePath), programInfo.ImagePath);
AppendValue(builder, nameof(programInfo.ImageUrl), programInfo.ImageUrl);
AppendValue(builder, nameof(programInfo.ThumbImageUrl), programInfo.ThumbImageUrl);
AppendValue(builder, nameof(programInfo.LogoImageUrl), programInfo.LogoImageUrl);
AppendValue(builder, nameof(programInfo.BackdropImageUrl), programInfo.BackdropImageUrl);
AppendValue(builder, nameof(programInfo.IsMovie), programInfo.IsMovie);
AppendValue(builder, nameof(programInfo.IsSports), programInfo.IsSports);
AppendValue(builder, nameof(programInfo.IsSeries), programInfo.IsSeries);
AppendValue(builder, nameof(programInfo.IsLive), programInfo.IsLive);
AppendValue(builder, nameof(programInfo.IsNews), programInfo.IsNews);
AppendValue(builder, nameof(programInfo.IsKids), programInfo.IsKids);
AppendValue(builder, nameof(programInfo.IsPremiere), programInfo.IsPremiere);
AppendValue(builder, nameof(programInfo.ProductionYear), programInfo.ProductionYear);
AppendValue(builder, nameof(programInfo.SeriesId), programInfo.SeriesId);
AppendValue(builder, nameof(programInfo.ShowId), programInfo.ShowId);
AppendValue(builder, nameof(programInfo.SeasonNumber), programInfo.SeasonNumber);
AppendValue(builder, nameof(programInfo.EpisodeNumber), programInfo.EpisodeNumber);
AppendDictionary(builder, nameof(programInfo.ProviderIds), programInfo.ProviderIds);
AppendDictionary(builder, nameof(programInfo.SeriesProviderIds), programInfo.SeriesProviderIds);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
etag = Prefix + Convert.ToHexString(hash);
reason = null;
return true;
}
private static void AppendValue(StringBuilder builder, string name, string? value)
{
builder.Append(name).Append('|');
if (value is null)
{
builder.Append('N').Append("|0|");
}
else
{
builder.Append('S')
.Append('|')
.Append(value.Length.ToString(CultureInfo.InvariantCulture))
.Append('|')
.Append(value);
}
builder.Append('\n');
}
private static void AppendValue(StringBuilder builder, string name, DateTime value)
=> AppendValue(builder, name, FormatDateTime(value));
private static void AppendValue(StringBuilder builder, string name, DateTime? value)
=> AppendValue(builder, name, value.HasValue ? FormatDateTime(value.Value) : null);
private static void AppendValue(StringBuilder builder, string name, bool value)
=> AppendValue(builder, name, value ? "true" : "false");
private static void AppendValue(StringBuilder builder, string name, bool? value)
=> AppendValue(builder, name, value switch { true => "true", false => "false", null => null });
private static void AppendValue(StringBuilder builder, string name, int? value)
=> AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture));
private static void AppendValue(StringBuilder builder, string name, float? value)
=> AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture));
// Treat Unspecified as UTC so the etag does not vary with the server's local timezone.
private static string FormatDateTime(DateTime value)
{
var utc = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc),
_ => value.ToUniversalTime(),
};
return utc.ToString("O", CultureInfo.InvariantCulture);
}
private static void AppendList(StringBuilder builder, string name, IReadOnlyList<string> values)
{
AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture));
for (var i = 0; i < values.Count; i++)
{
AppendValue(builder, $"{name}[{i}]", values[i]);
}
}
private static void AppendDictionary(StringBuilder builder, string name, IReadOnlyDictionary<string, string?> values)
{
AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture));
if (values.Count == 0)
{
return;
}
var index = 0;
foreach (var (key, value) in values
.OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Key, StringComparer.Ordinal))
{
AppendValue(builder, $"{name}[{index}].Key", key);
AppendValue(builder, $"{name}[{index}].Value", value);
index++;
}
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
namespace Jellyfin.MediaEncoding.Keyframes.FfProbe;
@@ -31,6 +32,7 @@ public static class FfProbeKeyframeExtractor
CreateNoWindow = true,
UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden,
@@ -0,0 +1,161 @@
using System;
using System.IO;
using Jellyfin.Api.Controllers;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.IO;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
namespace Jellyfin.Api.Tests.Controllers;
// The legacy HLS endpoints build a file path from caller-supplied route values, and the audio
// and video segment endpoints are not authenticated. These tests pin down that requests escaping
// the transcode directory are rejected while legitimate ones still serve a file.
public sealed class HlsSegmentControllerTests
{
private readonly Mock<IFileSystem> _fileSystem = new();
private readonly Mock<IServerConfigurationManager> _config = new();
private readonly Mock<ITranscodeManager> _transcodeManager = new();
private readonly string _transcodePath;
public HlsSegmentControllerTests()
{
_transcodePath = Path.Combine(Path.GetTempPath(), "jellyfin-hls-segment-tests");
Directory.CreateDirectory(_transcodePath);
_config.Setup(c => c.GetConfiguration("encoding"))
.Returns(new EncodingOptions { TranscodingTempPath = _transcodePath });
_config.SetupGet(c => c.CommonApplicationPaths).Returns(Mock.Of<IApplicationPaths>());
}
private HlsSegmentController CreateController(string requestPath)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Path = requestPath;
return new HlsSegmentController(_fileSystem.Object, _config.Object, _transcodeManager.Object)
{
ControllerContext = new ControllerContext { HttpContext = httpContext }
};
}
[Fact]
public void GetHlsAudioSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
{
var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
var result = controller.GetHlsAudioSegmentLegacy("abc", "segment");
Assert.IsType<PhysicalFileResult>(result);
}
[Theory]
[InlineData("../../../../etc/passwd")]
[InlineData("subdir/../../../../etc/passwd")]
public void GetHlsAudioSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
{
var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
var result = controller.GetHlsAudioSegmentLegacy("abc", segmentId);
Assert.IsType<BadRequestObjectResult>(result);
}
[Fact]
public void GetHlsAudioSegmentLegacy_AbsoluteRootedPath_ReturnsBadRequest()
{
var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
// A rooted segment id makes Path.GetFullPath discard the transcode base.
var rooted = OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd";
var result = controller.GetHlsAudioSegmentLegacy("abc", rooted);
Assert.IsType<BadRequestObjectResult>(result);
}
[Fact]
public void GetHlsAudioSegmentLegacy_SiblingPrefixDirectory_ReturnsBadRequest()
{
var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
// Resolves to "<transcodePath>-evil/passwd", which shares the transcode path as a string prefix.
var result = controller.GetHlsAudioSegmentLegacy("abc", "../jellyfin-hls-segment-tests-evil/passwd");
Assert.IsType<BadRequestObjectResult>(result);
}
[Fact]
public void GetHlsPlaylistLegacy_M3u8InsideTranscodePath_ReturnsFile()
{
var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
var result = controller.GetHlsPlaylistLegacy("abc", "list");
Assert.IsType<PhysicalFileResult>(result);
}
[Fact]
public void GetHlsPlaylistLegacy_NonPlaylistExtension_ReturnsBadRequest()
{
// Playlist endpoint serves only .m3u8, even for a path inside the transcode dir.
var controller = CreateController("/Videos/abc/hls/list/stream.mp4");
var result = controller.GetHlsPlaylistLegacy("abc", "list");
Assert.IsType<BadRequestObjectResult>(result);
}
[Theory]
[InlineData("../../../../etc/passwd")]
public void GetHlsPlaylistLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string playlistId)
{
var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
var result = controller.GetHlsPlaylistLegacy("abc", playlistId);
Assert.IsType<BadRequestObjectResult>(result);
}
[Fact]
public void GetHlsVideoSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
{
_fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
.Returns(new[] { Path.Combine(_transcodePath, "playlist123.ts") });
var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
Assert.IsType<PhysicalFileResult>(result);
}
[Fact]
public void GetHlsVideoSegmentLegacy_NoMatchingPlaylist_ReturnsNotFound()
{
_fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
.Returns(Array.Empty<string>());
var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
Assert.IsType<NotFoundObjectResult>(result);
}
[Theory]
[InlineData("../../../../etc/passwd")]
public void GetHlsVideoSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
{
var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", segmentId, "ts");
Assert.IsType<BadRequestObjectResult>(result);
_fileSystem.Verify(f => f.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
}
@@ -0,0 +1,129 @@
using System;
using System.IO;
using Jellyfin.Api.Controllers;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Common.Updates;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
namespace Jellyfin.Api.Tests.Controllers;
// Covers the path-traversal validation in GetPluginImage: a plugin's manifest ImagePath
// must resolve to a file inside the plugin's own directory.
public sealed class PluginsControllerTests
{
private readonly Mock<IPluginManager> _pluginManager = new();
private readonly string _pluginPath;
public PluginsControllerTests()
{
_pluginPath = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-image-tests");
Directory.CreateDirectory(_pluginPath);
}
private PluginsController CreateController() =>
new PluginsController(Mock.Of<IInstallationManager>(), _pluginManager.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
private void SetupPlugin(Guid id, Version version, string? imagePath)
{
var manifest = new PluginManifest { Id = id, Name = "Test", Version = version.ToString(), ImagePath = imagePath };
_pluginManager.Setup(p => p.GetPlugin(id, version))
.Returns(new LocalPlugin(_pluginPath, true, manifest));
}
[Fact]
public void GetPluginImage_UnknownPlugin_ReturnsNotFound()
{
var result = CreateController().GetPluginImage(Guid.NewGuid(), new Version(1, 0));
Assert.IsType<NotFoundResult>(result);
}
[Fact]
public void GetPluginImage_ImageInsidePluginPath_ReturnsFile()
{
var id = Guid.NewGuid();
var version = new Version(1, 0);
File.WriteAllBytes(Path.Combine(_pluginPath, "logo.png"), Array.Empty<byte>());
SetupPlugin(id, version, "logo.png");
var result = CreateController().GetPluginImage(id, version);
Assert.IsType<PhysicalFileResult>(result);
}
[Fact]
public void GetPluginImage_ImageInsidePluginPathButMissing_ReturnsNotFound()
{
var id = Guid.NewGuid();
var version = new Version(1, 0);
SetupPlugin(id, version, "does-not-exist.png");
var result = CreateController().GetPluginImage(id, version);
Assert.IsType<NotFoundResult>(result);
}
[Theory]
[InlineData("../../../../etc/passwd")]
[InlineData("subdir/../../../../etc/passwd")]
public void GetPluginImage_TraversalOutsidePluginPath_ReturnsNotFound(string imagePath)
{
var id = Guid.NewGuid();
var version = new Version(1, 0);
SetupPlugin(id, version, imagePath);
var result = CreateController().GetPluginImage(id, version);
Assert.IsType<NotFoundResult>(result);
}
[Fact]
public void GetPluginImage_SiblingPrefixDirectory_ReturnsNotFound()
{
var id = Guid.NewGuid();
var version = new Version(1, 0);
// Resolves to "<pluginPath>-evil/logo.png", which shares the plugin path as a string prefix.
// The file is created so the check fails on the boundary, not on File.Exists.
var siblingDir = _pluginPath + "-evil";
Directory.CreateDirectory(siblingDir);
File.WriteAllBytes(Path.Combine(siblingDir, "logo.png"), Array.Empty<byte>());
SetupPlugin(id, version, "../jellyfin-plugin-image-tests-evil/logo.png");
var result = CreateController().GetPluginImage(id, version);
Assert.IsType<NotFoundResult>(result);
}
[Fact]
public void GetPluginImage_AbsoluteImagePath_ReturnsNotFound()
{
var id = Guid.NewGuid();
var version = new Version(1, 0);
SetupPlugin(id, version, OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd");
var result = CreateController().GetPluginImage(id, version);
Assert.IsType<NotFoundResult>(result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void GetPluginImage_NoImagePathOrResource_ReturnsNotFound(string? imagePath)
{
var id = Guid.NewGuid();
var version = new Version(1, 0);
SetupPlugin(id, version, imagePath);
var result = CreateController().GetPluginImage(id, version);
Assert.IsType<NotFoundResult>(result);
}
}
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.AutoMoq;
using Jellyfin.LiveTv.Listings;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
using Moq;
using Moq.Protected;
@@ -66,6 +67,7 @@ public class XmlTvListingsProviderTests
Assert.True(program.HasImage);
Assert.Equal("https://domain.tld/image.png", program.ImageUrl);
Assert.Equal("3297", program.ChannelId);
AssertXmlTvEtag(program.Etag);
}
[Theory]
@@ -85,5 +87,60 @@ public class XmlTvListingsProviderTests
var program = programsList[0];
Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g));
Assert.Equal("3297", program.ChannelId);
AssertXmlTvEtag(program.Etag);
}
[Fact]
public async Task GetProgramsAsync_Etag_SameContentIsStable()
{
var first = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
var second = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
Assert.Equal(first.Etag, second.Etag);
}
[Theory]
[InlineData("Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml")]
[InlineData("Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml")]
[InlineData("Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml")]
[InlineData("Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml")]
[InlineData("Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml")]
public async Task GetProgramsAsync_Etag_ChangesWhenMappedContentChanges(string changedPath)
{
var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
var changed = await GetSingleProgramAsync(changedPath);
Assert.NotEqual(original.Etag, changed.Etag);
}
[Theory]
[InlineData("Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml")]
[InlineData("Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml")]
public async Task GetProgramsAsync_Etag_DoesNotChangeWhenMappedContentIsEquivalent(string equivalentPath)
{
var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
var equivalent = await GetSingleProgramAsync(equivalentPath);
Assert.Equal(original.Etag, equivalent.Etag);
}
private async Task<ProgramInfo> GetSingleProgramAsync(string path)
{
var info = new ListingsProviderInfo()
{
Id = Path.GetFileNameWithoutExtension(path),
Path = path
};
var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None);
return Assert.Single(programs.ToList());
}
private static void AssertXmlTvEtag(string? etag)
{
Assert.NotNull(etag);
Assert.StartsWith("xmltv-sha256-v1:", etag!, StringComparison.Ordinal);
}
}
@@ -0,0 +1,59 @@
using System;
using Jellyfin.LiveTv.Listings;
using MediaBrowser.Controller.LiveTv;
using Xunit;
namespace Jellyfin.LiveTv.Tests.Listings;
public class XmlTvProgramEtagTests
{
[Fact]
public void TryCreate_GenreOrderIsSignificant()
{
// GuideManager assigns item.Genres = info.Genres.ToArray() preserving order,
// so the same genres in a different order is a real mapped-content change.
var first = NewProgram();
first.Genres = new() { "Drama", "Action" };
var second = NewProgram();
second.Genres = new() { "Action", "Drama" };
Assert.True(XmlTvProgramEtag.TryCreate(first, out var firstEtag, out _));
Assert.True(XmlTvProgramEtag.TryCreate(second, out var secondEtag, out _));
Assert.NotEqual(firstEtag, secondEtag);
}
[Fact]
public void MatchesStored_EqualXmlTvEtags_ReturnsTrue()
{
const string Etag = XmlTvProgramEtag.Prefix + "ABCDEF0123456789";
Assert.True(XmlTvProgramEtag.MatchesStored(Etag, Etag));
}
[Fact]
public void MatchesStored_DifferentXmlTvEtags_ReturnsFalse()
{
Assert.False(XmlTvProgramEtag.MatchesStored(
XmlTvProgramEtag.Prefix + "AAAA",
XmlTvProgramEtag.Prefix + "BBBB"));
}
[Fact]
public void MatchesStored_EqualNonXmlTvEtags_ReturnsFalse()
{
// Other providers (e.g. Schedules Direct) use their own etag schemes.
// The IsXmlTvEtag gate must keep them on the field-by-field update path
// even when their incoming and stored values happen to match exactly.
const string Etag = "sd-abc123";
Assert.False(XmlTvProgramEtag.MatchesStored(Etag, Etag));
}
private static ProgramInfo NewProgram() => new()
{
Id = "program-id",
ChannelId = "channel-id",
Name = "Program Name",
StartDate = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc),
EndDate = new DateTime(2026, 1, 1, 13, 0, 0, DateTimeKind.Utc),
};
}
@@ -175,6 +175,30 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect
Assert.Equal("Series", showImagesDtos[0].Data[0].Tier);
}
/// <summary>
/// /metadata/programs response where the daily image limit is hit mid-batch,
/// so individual entries carry an error code inside an otherwise successful response.
/// </summary>
[Fact]
public void Deserialize_Metadata_Programs_Image_Limit_Response_Success()
{
var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_image_limit_response.json");
var showImagesDtos = JsonSerializer.Deserialize<IReadOnlyList<ShowImagesDto>>(bytes, _jsonOptions);
Assert.NotNull(showImagesDtos);
Assert.Equal(2, showImagesDtos!.Count);
// First entry is a normal result with image data and no error code.
Assert.Equal("SH00712240", showImagesDtos[0].ProgramId);
Assert.Null(showImagesDtos[0].Code);
Assert.Single(showImagesDtos[0].Data);
// Second entry is a per-entry trial image download limit error (SD code 5003).
Assert.Equal("SH00712241", showImagesDtos[1].ProgramId);
Assert.Equal((int)SdErrorCode.MaxImageDownloadsTrial, showImagesDtos[1].Code);
Assert.Empty(showImagesDtos[1].Data);
}
/// <summary>
/// /headends response.
/// </summary>
@@ -0,0 +1,17 @@
<tv date="20221104">
<programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
<title lang="en">Base Program</title>
<sub-title lang="en">Base Episode</sub-title>
<desc lang="en">Base description.</desc>
<category lang="en">series</category>
<episode-num system="xmltv_ns">0 . 1 . </episode-num>
<episode-num system="dd_progid">EP123456789012</episode-num>
<rating system="VCHIP">
<value>TV-G</value>
</rating>
<star-rating>
<value>3/5</value>
</star-rating>
<icon src="https://domain.tld/base.png"/>
</programme>
</tv>
@@ -0,0 +1,17 @@
<tv date="20221104">
<programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
<title lang="en">Base Program</title>
<sub-title lang="en">Base Episode</sub-title>
<desc lang="en">Base description.</desc>
<category lang="en">sports</category>
<episode-num system="xmltv_ns">0 . 1 . </episode-num>
<episode-num system="dd_progid">EP123456789012</episode-num>
<rating system="VCHIP">
<value>TV-G</value>
</rating>
<star-rating>
<value>3/5</value>
</star-rating>
<icon src="https://domain.tld/base.png"/>
</programme>
</tv>
@@ -0,0 +1,17 @@
<tv date="20221104">
<programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
<title lang="en">Base Program</title>
<sub-title lang="en">Base Episode</sub-title>
<desc lang="en">Changed description.</desc>
<category lang="en">series</category>
<episode-num system="xmltv_ns">0 . 1 . </episode-num>
<episode-num system="dd_progid">EP123456789012</episode-num>
<rating system="VCHIP">
<value>TV-G</value>
</rating>
<star-rating>
<value>3/5</value>
</star-rating>
<icon src="https://domain.tld/base.png"/>
</programme>
</tv>
@@ -0,0 +1,17 @@
<tv date="20221104">
<programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
<title lang="en">Base Program</title>
<sub-title lang="en">Base Episode</sub-title>
<desc lang="en">Base description.</desc>
<category lang="en">series</category>
<episode-num system="xmltv_ns">0 . 1 . </episode-num>
<episode-num system="dd_progid">EP123456789012</episode-num>
<rating system="VCHIP">
<value>TV-G</value>
</rating>
<star-rating>
<value>3/5</value>
</star-rating>
<icon src="https://domain.tld/changed.png"/>
</programme>
</tv>
@@ -0,0 +1,17 @@
<tv date="20221104">
<programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
<title lang="en">Base Program</title>
<sub-title lang="en">Base Episode</sub-title>
<desc lang="en">Base description.</desc>
<category lang="en">series</category>
<episode-num system="xmltv_ns">0 . 1 . </episode-num>
<episode-num system="dd_progid">EP123456789013</episode-num>
<rating system="VCHIP">
<value>TV-G</value>
</rating>
<star-rating>
<value>3/5</value>
</star-rating>
<icon src="https://domain.tld/base.png"/>
</programme>
</tv>
@@ -0,0 +1,17 @@
<tv date="20221104">
<programme channel="3297" stop="20221104140000 +0000" start="20221104130000 +0000">
<icon src="https://domain.tld/base.png"/>
<star-rating>
<value>3/5</value>
</star-rating>
<rating system="VCHIP">
<value>TV-G</value>
</rating>
<episode-num system="xmltv_ns">0 . 1 . </episode-num>
<episode-num system="dd_progid">EP123456789012</episode-num>
<category lang="en">series</category>
<desc lang="en">Base description.</desc>
<sub-title lang="en">Base Episode</sub-title>
<title lang="en">Base Program</title>
</programme>
</tv>
@@ -0,0 +1,17 @@
<tv date="20221104">
<programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
<title lang="en">Changed Program</title>
<sub-title lang="en">Base Episode</sub-title>
<desc lang="en">Base description.</desc>
<category lang="en">series</category>
<episode-num system="xmltv_ns">0 . 1 . </episode-num>
<episode-num system="dd_progid">EP123456789012</episode-num>
<rating system="VCHIP">
<value>TV-G</value>
</rating>
<star-rating>
<value>3/5</value>
</star-rating>
<icon src="https://domain.tld/base.png"/>
</programme>
</tv>
@@ -0,0 +1,18 @@
<tv date="20221104">
<programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
<title lang="en">Base Program</title>
<sub-title lang="en">Base Episode</sub-title>
<desc lang="en">Base description.</desc>
<category lang="en">series</category>
<episode-num system="xmltv_ns">0 . 1 . </episode-num>
<episode-num system="dd_progid">EP123456789012</episode-num>
<rating system="VCHIP">
<value>TV-G</value>
</rating>
<star-rating>
<value>3/5</value>
</star-rating>
<previously-unknown-field>Ignored by Jellyfin XMLTV mapping.</previously-unknown-field>
<icon src="https://domain.tld/base.png"/>
</programme>
</tv>
@@ -0,0 +1 @@
[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]},{"programID":"SH00712241","code":5003,"message":"Image download limit exceeded. Try again tomorrow."}]
@@ -24,6 +24,7 @@ namespace Jellyfin.MediaEncoding.Tests
[InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)]
[InlineData(EncoderValidatorTestsData.FFmpegV432Output, false)]
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, true)]
[InlineData(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, true)]
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)]
public void ValidateVersionInternalTest(string versionOutput, bool valid)
{
@@ -41,6 +42,7 @@ namespace Jellyfin.MediaEncoding.Tests
Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2));
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null);
}
}
@@ -86,6 +86,15 @@ libswscale 5. 9.100 / 5. 9.100
libswresample 3. 9.100 / 3. 9.100
libpostproc 55. 9.100 / 55. 9.100";
public const string FFmpegGitWithoutLibpostprocOutput = @"ffmpeg version N-122128-gdeadbeef Copyright (c) 2000-2026 the FFmpeg developers
libavutil 60. 26.102 / 60. 26.102
libavcodec 62. 28.102 / 62. 28.102
libavformat 62. 12.102 / 62. 12.102
libavdevice 62. 3.102 / 62. 3.102
libavfilter 11. 14.102 / 11. 14.102
libswscale 9. 5.102 / 9. 5.102
libswresample 6. 3.102 / 6. 3.102";
public const string FFmpegGitUnknownOutput = @"ffmpeg version N-45325-gb173e0353-static https://johnvansickle.com/ffmpeg/ Copyright (c) 2000-2018 the FFmpeg developers
built with gcc 6.3.0 (Debian 6.3.0-18+deb9u1) 20170516
configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc-6 --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gray --enable-libfribidi --enable-libass --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libzimg
@@ -15,13 +15,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.ass");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "ass");
Assert.Single(parsed.TrackEvents);
var trackEvent = parsed.TrackEvents[0];
Assert.Single(parsed.Paragraphs);
var paragraph = parsed.Paragraphs[0];
Assert.Equal("1", trackEvent.Id);
Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks);
Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks);
Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text);
Assert.Equal(1, paragraph.Number);
Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks);
Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks);
Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", paragraph.Text);
}
}
}
@@ -15,19 +15,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.srt");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt");
Assert.Equal(2, parsed.TrackEvents.Count);
Assert.Equal(2, parsed.Paragraphs.Count);
var trackEvent1 = parsed.TrackEvents[0];
Assert.Equal("1", trackEvent1.Id);
Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks);
Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks);
Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text);
var paragraph1 = parsed.Paragraphs[0];
Assert.Equal(1, paragraph1.Number);
Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks);
Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks);
Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", paragraph1.Text);
var trackEvent2 = parsed.TrackEvents[1];
Assert.Equal("2", trackEvent2.Id);
Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks);
Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks);
Assert.Equal("Very good, Lieutenant.", trackEvent2.Text);
var paragraph2 = parsed.Paragraphs[1];
Assert.Equal(2, paragraph2.Number);
Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks);
Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks);
Assert.Equal("Very good, Lieutenant.", paragraph2.Text);
}
[Fact]
@@ -36,19 +36,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example2.srt");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt");
Assert.Equal(2, parsed.TrackEvents.Count);
Assert.Equal(2, parsed.Paragraphs.Count);
var trackEvent1 = parsed.TrackEvents[0];
Assert.Equal("311", trackEvent1.Id);
Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks);
Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks);
Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", trackEvent1.Text);
var paragraph1 = parsed.Paragraphs[0];
Assert.Equal(311, paragraph1.Number);
Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks);
Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks);
Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", paragraph1.Text);
var trackEvent2 = parsed.TrackEvents[1];
Assert.Equal("312", trackEvent2.Id);
Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks);
Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks);
Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", trackEvent2.Text);
var paragraph2 = parsed.Paragraphs[1];
Assert.Equal(312, paragraph2.Number);
Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks);
Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks);
Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", paragraph2.Text);
}
}
}
@@ -20,19 +20,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
{
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa));
SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, "ssa");
var subtitle = _parser.Parse(stream, "ssa");
Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count);
Assert.Equal(expectedSubtitleTrackEvents.Count, subtitle.Paragraphs.Count);
for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i)
{
SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i];
SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i];
var actual = subtitle.Paragraphs[i];
Assert.Equal(expected.Id, actual.Id);
Assert.Equal(expected.Id, actual.Number.ToString(CultureInfo.InvariantCulture));
Assert.Equal(expected.Text, actual.Text);
Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks);
Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks);
Assert.Equal(expected.StartPositionTicks, actual.StartTime.TimeSpan.Ticks);
Assert.Equal(expected.EndPositionTicks, actual.EndTime.TimeSpan.Ticks);
}
}
@@ -75,13 +75,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.ssa");
var parsed = _parser.Parse(stream, "ssa");
Assert.Single(parsed.TrackEvents);
var trackEvent = parsed.TrackEvents[0];
Assert.Single(parsed.Paragraphs);
var paragraph = parsed.Paragraphs[0];
Assert.Equal("1", trackEvent.Id);
Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks);
Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks);
Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text);
Assert.Equal(1, paragraph.Number);
Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks);
Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks);
Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", paragraph.Text);
}
}
}
@@ -1,3 +1,8 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutoFixture;
@@ -6,12 +11,16 @@ using MediaBrowser.MediaEncoding.Subtitles;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.MediaEncoding.Subtitles.Tests
{
public class SubtitleEncoderTests
{
private const int StreamCount = 8;
private const int CueCount = 500;
public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData()
{
var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>();
@@ -103,5 +112,90 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
Assert.Equal(subtitleInfo.Format, result.Format);
Assert.Equal(subtitleInfo.IsExternal, result.IsExternal);
}
[Fact]
public void ConvertSubtitles_SequentialCalls_AreDeterministic()
{
using var encoder = CreateEncoder();
var sources = GenerateSources();
var first = ConvertAllSequential(encoder, sources);
var second = ConvertAllSequential(encoder, sources);
for (var i = 0; i < StreamCount; i++)
{
Assert.Contains($"S{i}C{CueCount - 1}", first[i], StringComparison.Ordinal);
Assert.Equal(first[i], second[i]);
}
}
[Fact]
public async Task ConvertSubtitles_ConcurrentCalls_MatchSequentialBaseline()
{
const int Iterations = 10;
using var encoder = CreateEncoder();
var sources = GenerateSources();
var baseline = ConvertAllSequential(encoder, sources);
for (var iteration = 0; iteration < Iterations; iteration++)
{
var results = await Task.WhenAll(Enumerable.Range(0, StreamCount)
.Select(i => Task.Run(() => Convert(encoder, sources[i], i)))
.ToArray());
for (var i = 0; i < StreamCount; i++)
{
Assert.True(
string.Equals(baseline[i], results[i], StringComparison.Ordinal),
$"Iteration {iteration}: stream {i} returned corrupted content ({results[i].Length} chars vs {baseline[i].Length} baseline)");
}
}
}
private static SubtitleEncoder CreateEncoder()
{
var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
fixture.Inject<ISubtitleParser>(new SubtitleEditParser(NullLogger<SubtitleEditParser>.Instance));
return fixture.Create<SubtitleEncoder>();
}
private static byte[][] GenerateSources()
{
return Enumerable.Range(0, StreamCount)
.Select(i => Encoding.UTF8.GetBytes(GenerateSrt(i, CueCount)))
.ToArray();
}
private static string Convert(SubtitleEncoder encoder, byte[] source, int streamIndex)
{
using var input = new MemoryStream(source);
var info = new SubtitleEncoder.SubtitleInfo { Path = $"track{streamIndex}.srt", Format = "srt" };
using var output = encoder.ConvertSubtitles(input, info, "vtt", 0, 0, false);
return Encoding.UTF8.GetString(output.ToArray());
}
private static string[] ConvertAllSequential(SubtitleEncoder encoder, byte[][] sources)
{
return sources.Select((source, i) => Convert(encoder, source, i)).ToArray();
}
private static string GenerateSrt(int streamIndex, int cueCount)
{
var builder = new StringBuilder();
for (var i = 0; i < cueCount; i++)
{
var start = TimeSpan.FromSeconds(i * 4);
var end = start + TimeSpan.FromSeconds(2);
builder.Append(i + 1).AppendLine()
.Append(start.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture))
.Append(" --> ")
.AppendLine(end.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture))
.Append('S').Append(streamIndex).Append('C').Append(i).AppendLine()
.AppendLine();
}
return builder.ToString();
}
}
}
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -56,57 +57,63 @@ public class DtoServiceTests
}
[Fact]
public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries()
public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage()
{
var (episode, season, series) = BuildEpisode(seasonHasPoster: true);
var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
var (episode, season, _) = BuildEpisode(seasonHasPoster: true);
var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] };
var dto = _dtoService.GetBaseItemDto(episode, options);
// The episode's own 16:9 primary is dropped in favor of the season's portrait poster.
Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
Assert.Null(dto.SeriesPrimaryImageTag);
// The season poster is attached additively; the episode keeps its own primary and 16:9 ratio,
// and clients decide per view whether to prefer the parent/series poster over the episode still.
Assert.NotNull(dto.ImageTags);
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
// Aspect ratio follows the (portrait) poster, not the episode's 16:9 image.
Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
// Aspect ratio stays the episode's own image, not the poster's.
Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
}
[Fact]
public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster()
public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster()
{
var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
var options = new DtoOptions(false);
var dto = _dtoService.GetBaseItemDto(episode, options);
Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
Assert.Null(dto.SeriesPrimaryImageTag);
// Episode image is retained; ParentPrimaryImage falls back to the series poster.
Assert.NotNull(dto.ImageTags);
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
}
[Fact]
public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary()
public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary()
{
var (episode, _, _) = BuildEpisode(seasonHasPoster: true);
var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false);
var options = new DtoOptions(false);
var dto = _dtoService.GetBaseItemDto(episode, options);
// Default behavior: the episode keeps its own primary and exposes the series poster as a tag.
// With no season or series poster there is nothing to attach; the episode keeps its own primary.
Assert.NotNull(dto.ImageTags);
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Null(dto.ParentPrimaryImageItemId);
}
private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster)
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
// item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
if (seriesHasPoster)
{
series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
}
var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
if (seasonHasPoster)
@@ -60,7 +60,9 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
.Where(e => seededIds.Contains(e.Id))
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Where(s => s.Id != e.Id
&& inProgressIds.Contains(s.Id)
&& (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
@@ -110,7 +112,9 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
.Where(e => seededIds.Contains(e.Id))
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Where(s => s.Id != e.Id
&& inProgressIds.Contains(s.Id)
&& (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
@@ -150,7 +150,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
}
[Fact]
public void GetStaticMediaSources_PrimaryQueried_PopulatesPerVersionPositionsAndDefaultsToMostRecent()
public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
SetupUserDataBatch(new Dictionary<Guid, UserItemData>
@@ -161,12 +161,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
// Each version carries its own resume point; the primary has none.
Assert.Equal((long?)10, sources.First(s => s.Id == alt1.Id.ToString("N")).PlaybackPositionTicks);
Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
Assert.Null(sources.First(s => s.Id == primary.Id.ToString("N")).PlaybackPositionTicks);
// The most recently played version is the default source, so resuming plays the right file.
// Per-user positions live in each version's UserData, not on the source.
Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
}
@@ -182,9 +178,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library
var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user);
// An explicitly opened version keeps its own source first, even when a sibling was
// played more recently, but the sibling's resume point is still populated.
// played more recently.
Assert.Equal(alt1.Id.ToString("N"), sources[0].Id);
Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
Assert.Equal(3, sources.Count);
}
@@ -197,7 +192,6 @@ namespace Jellyfin.Server.Implementations.Tests.Library
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
Assert.All(sources, s => Assert.Null(s.PlaybackPositionTicks));
}
[Fact]
@@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library
{
[Theory]
[InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son", "imdbid", null)]
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")]
[InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")]
[InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")]
[InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")]
[InlineData("Superman: Red Son [providera id=4]", "providera id", "4")]
[InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")]
[InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")]
[InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
[InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
[InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")]
[InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")]
[InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")]
[InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")]
[InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")]
[InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")]
[InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")]
[InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")]
[InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")]
[InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
[InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")]
[InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")]
[InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")]
[InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")]
[InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")]
[InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")]
[InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")]
[InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")]
[InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")]
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
[InlineData("[tmdb=618355]", "tmdbid", "618355")]
[InlineData("{tmdbid=618355}", "tmdbid", "618355")]
[InlineData("{tmdb=618355}", "tmdbid", "618355")]
[InlineData("(tmdbid=618355)", "tmdbid", "618355")]
[InlineData("(tmdb=618355)", "tmdbid", "618355")]
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
[InlineData("[tmdb-618355]", "tmdbid", "618355")]
[InlineData("{tmdbid-618355)", "tmdbid", null)]
[InlineData("{tmdb-618355)", "tmdbid", null)]
[InlineData("[tmdbid-618355}", "tmdbid", null)]
[InlineData("[tmdb-618355}", "tmdbid", null)]
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
[InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")]
[InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")]
[InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")]
[InlineData("tmdbid=618355]", "tmdbid", null)]
[InlineData("tmdb=618355]", "tmdbid", null)]
[InlineData("[tmdbid=618355", "tmdbid", null)]
[InlineData("[tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=618355", "tmdbid", null)]
[InlineData("tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=", "tmdbid", null)]
[InlineData("tmdb=", "tmdbid", null)]
[InlineData("tmdbid", "tmdbid", null)]
[InlineData("tmdb", "tmdbid", null)]
[InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")]
[InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")]
[InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")]
[InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")]
[InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)]
[InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)]
[InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)]
[InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)]
[InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")]
[InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")]
[InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)]
[InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)]
[InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)]
[InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)]
[InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")]
[InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")]
[InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")]
[InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")]
public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult)
{
Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute));
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
namespace Jellyfin.Server.Implementations.Tests.Library;
public sealed class UserDataManagerTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly UserDataManager _userDataManager;
private readonly User _user;
public UserDataManagerTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
var config = new Mock<IServerConfigurationManager>();
config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
_userDataManager = new UserDataManager(config.Object, factory.Object);
_user = new User("user", "auth-provider", "reset-provider")
{
Id = Guid.NewGuid()
};
}
public void Dispose()
{
_connection.Dispose();
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
private AudioBook CreateAudioBook()
{
// GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"]
return new AudioBook
{
Id = Guid.NewGuid(),
Name = "Book Title",
Album = "Series",
AlbumArtists = new[] { "Author" },
IndexNumber = 1
};
}
private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks)
{
return new UserData
{
ItemId = item.Id,
Item = null,
UserId = _user.Id,
User = null,
CustomDataKey = key,
PlaybackPositionTicks = positionTicks
};
}
[Fact]
public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow()
{
var item = CreateAudioBook();
var currentKey = item.GetUserDataKeys()[0];
// the retired-key row comes first to ensure selection is by key, not row order
item.UserData = new List<UserData>
{
CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
CreateUserDataRow(item, currentKey, 222)
};
var userData = _userDataManager.GetUserData(_user, item);
Assert.NotNull(userData);
Assert.Equal(currentKey, userData.Key);
Assert.Equal(222, userData.PlaybackPositionTicks);
}
[Fact]
public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow()
{
var item = CreateAudioBook();
var idKey = item.GetUserDataKeys()[1];
item.UserData = new List<UserData>
{
CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
CreateUserDataRow(item, idKey, 333)
};
var userData = _userDataManager.GetUserData(_user, item);
Assert.NotNull(userData);
Assert.Equal(idKey, userData.Key);
Assert.Equal(333, userData.PlaybackPositionTicks);
}
[Fact]
public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow()
{
var item = CreateAudioBook();
item.UserData = new List<UserData>
{
CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)
};
var userData = _userDataManager.GetUserData(_user, item);
Assert.NotNull(userData);
Assert.Equal(111, userData.PlaybackPositionTicks);
}
[Fact]
public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey()
{
var item = CreateAudioBook();
item.UserData = new List<UserData>();
var userData = _userDataManager.GetUserData(_user, item);
Assert.NotNull(userData);
Assert.Equal(item.GetUserDataKeys()[0], userData.Key);
Assert.Equal(0, userData.PlaybackPositionTicks);
}
[Fact]
public void GetUserData_RowsForOtherUsers_AreIgnored()
{
var item = CreateAudioBook();
var currentKey = item.GetUserDataKeys()[0];
var otherUserRow = CreateUserDataRow(item, currentKey, 999);
otherUserRow.UserId = Guid.NewGuid();
item.UserData = new List<UserData>
{
otherUserRow,
CreateUserDataRow(item, currentKey, 222)
};
var userData = _userDataManager.GetUserData(_user, item);
Assert.NotNull(userData);
Assert.Equal(222, userData.PlaybackPositionTicks);
}
[Fact]
public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
{
// no preloaded navigation data, so the batch takes the database fallback
var fossilItem = CreateAudioBook();
var retiredItem = CreateAudioBook();
using (var ctx = CreateDbContext())
{
ctx.Users.Add(_user);
ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! });
ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! });
// the stale id-key row is inserted first so selection by row order would return it
ctx.UserData.AddRange(
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222),
CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
ctx.SaveChanges();
}
var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
}
}
@@ -0,0 +1,142 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public sealed class UserManagerProfileImageTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly UserManager _userManager;
public UserManagerProfileImageTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
// Create the schema
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateDbContext);
var cryptoProvider = new Mock<ICryptoProvider>();
var configManager = new Mock<IServerConfigurationManager>();
var appPaths = new Mock<IServerApplicationPaths>();
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
var appHost = new Mock<IApplicationHost>();
var defaultAuthProvider = new DefaultAuthenticationProvider(
NullLogger<DefaultAuthenticationProvider>.Instance,
cryptoProvider.Object);
var invalidAuthProvider = new InvalidAuthProvider();
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
configManager.Object,
appHost.Object);
_userManager = new UserManager(
factory.Object,
new NoopEventManager(),
new Mock<INetworkManager>().Object,
appHost.Object,
new Mock<IImageProcessor>().Object,
NullLogger<UserManager>.Instance,
configManager.Object,
new IPasswordResetProvider[] { defaultPasswordResetProvider },
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
}
public void Dispose()
{
_userManager.Dispose();
_connection.Dispose();
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
[Fact]
public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage()
{
var user = await _userManager.CreateUserAsync("profileimageuser");
// Assign a profile image the same way the image endpoint does and persist it.
// UpdateUserAsync creates the persisted ImageInfo on a separately loaded db entity,
// so the in-memory instance below is never assigned the database generated key.
user.ProfileImage = new ImageInfo(Path.Combine(Path.GetTempPath(), "profile.png"));
await _userManager.UpdateUserAsync(user);
// Precondition reproducing the bug: the in-memory image still carries the default,
// never-persisted (temporary) key, while a real image row exists in the database.
Assert.Equal(0, user.ProfileImage.Id);
Assert.NotNull(_userManager.GetUserById(user.Id)!.ProfileImage);
// This used to throw InvalidOperationException:
// "The property 'ImageInfo.Id' has a temporary value while attempting to change the entity's state to 'Deleted'."
var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
Assert.Null(exception);
Assert.Null(user.ProfileImage);
Assert.Null(_userManager.GetUserById(user.Id)!.ProfileImage);
}
[Fact]
public async Task ClearProfileImageAsync_WhenNoProfileImage_DoesNothing()
{
var user = await _userManager.CreateUserAsync("noprofileimageuser");
var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
Assert.Null(exception);
Assert.Null(user.ProfileImage);
}
private sealed class NoopEventManager : IEventManager
{
public void Publish<T>(T eventArgs)
where T : EventArgs
{
}
public Task PublishAsync<T>(T eventArgs)
where T : EventArgs
=> Task.CompletedTask;
}
}
}