aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2026-07-20 20:49:37 -0400
committerGitHub <noreply@github.com>2026-07-20 20:49:37 -0400
commit557b14e33ea8707ebae39b141d003d46141d6f5f (patch)
treeddd70556add28e49e16b9a51a669d443e0bf3cf1
parent0d629591ed8b491e6e3560f72b12d737e4f3922f (diff)
parentc222d370cedcc4452a7246baff3494b29875a708 (diff)
Merge branch 'master' into fix/backup-skip-corrupt-keyframe-data
-rw-r--r--.github/workflows/commands.yml2
-rw-r--r--.github/workflows/issue-template-check.yml2
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs18
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs6
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs18
-rw-r--r--Emby.Server.Implementations/Library/PathExtensions.cs81
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs21
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs88
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json40
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json4
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs3
-rw-r--r--Jellyfin.Api/Controllers/ActivityLogController.cs31
-rw-r--r--Jellyfin.Api/Controllers/HlsSegmentController.cs42
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs4
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs9
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs2
-rw-r--r--Jellyfin.Api/Extensions/DtoExtensions.cs2
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs8
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs7
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs22
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs1
-rw-r--r--MediaBrowser.Controller/Dto/DtoOptions.cs7
-rw-r--r--MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs5
-rw-r--r--MediaBrowser.Controller/Entities/IHasStartDate.cs8
-rw-r--r--MediaBrowser.Controller/Entities/IItemByName.cs15
-rw-r--r--MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs5
-rw-r--r--MediaBrowser.Controller/Entities/SourceType.cs16
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs2
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs6
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs21
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs6
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs20
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs244
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs1
-rw-r--r--MediaBrowser.Model/Dto/MediaSourceInfo.cs5
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs26
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs9
-rwxr-xr-xMediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs30
-rw-r--r--README.md36
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs27
-rw-r--r--src/Jellyfin.LiveTv/IO/EncodedRecorder.cs1
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs18
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs184
-rw-r--r--src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs2
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs161
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs129
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs57
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs59
-rw-r--r--tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs24
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml18
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json1
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs9
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs12
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs52
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs24
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs94
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs43
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs8
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs12
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs63
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs209
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs142
72 files changed, 1854 insertions, 516 deletions
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index 6a0d7caa2e..b43ed3ee27 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -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'
diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml
index 9dec170d70..1d3294798b 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -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'
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 5c76c4014a..8cbf42585d 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -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);
}
}
diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index b701e7eb6d..7cae2a671b 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -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,
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index c64833ddaa..97e00177b6 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -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(
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
index 7591359ea4..7d0f3900c5 100644
--- a/Emby.Server.Implementations/Library/PathExtensions.cs
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -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);
+ // 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
+ {
+ _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
+ _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
+ _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
+ _ => ReadOnlySpan<char>.Empty
+ };
- // 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)
+ for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
{
- var attributeEnd = attributeIndex + attribute.Length;
+ // 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] == '-'))
+
+ if (attributeCloser != '\0')
{
- var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
+ if (shortAttr.Length > 0
+ && attributeEnd + 1 < subStr.Length
+ && (subStr[attributeEnd] is 'i' or 'I')
+ && (subStr[attributeEnd + 1] is 'd' or 'D'))
+ {
+ // We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
+ attributeEnd += 2;
+ }
- // Must be at least 1 character before the closing bracket.
- if (closingIndex > 1)
+ // 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 '-'))
{
- return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
+ 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;
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
index 68b66ab7f5..80375ae12d 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
@@ -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);
+
+ // 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);
+ }
- item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
+ return id;
+ }
if (!string.IsNullOrEmpty(item.Path))
{
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 40cd2bb69c..f5c41e5670 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -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)
- .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)
+ 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)
+ {
+ 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());
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 4fb9f4329c..8605a752db 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index 8794339fb1..442c26b30b 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index 5dace3b0b7..4e0dff87b9 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
"CleanupUserDataTask": "用戶資料清理工作",
- "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
+ "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
+ "Original": "原作",
+ "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
index b2dc89be28..e4939205c9 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
@@ -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
},
})
diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs
index d6cc0e71a4..8e17dd7d53 100644
--- a/Jellyfin.Api/Controllers/ActivityLogController.cs
+++ b/Jellyfin.Api/Controllers/ActivityLogController.cs
@@ -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;
- }
}
diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs
index b5365cd632..c61ee45830 100644
--- a/Jellyfin.Api/Controllers/HlsSegmentController.cs
+++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs
@@ -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);
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 70b09a4a29..e26264d292 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -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;
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index 0105ecf7a7..2c84fde972 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -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();
}
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index 25f781e496..a718035528 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -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
{
diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs
index 09793553c2..36a8e197a2 100644
--- a/Jellyfin.Api/Extensions/DtoExtensions.cs
+++ b/Jellyfin.Api/Extensions/DtoExtensions.cs
@@ -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:
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index 5072f902da..d14c3a9343 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -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++)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 52cebccc37..1198f99473 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -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)
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 583f29f94f..41648268a9 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -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;
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index 2aadedfa61..6db0edb237 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -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 =>
{
diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs
index d319feb6b2..052626355f 100644
--- a/MediaBrowser.Controller/Dto/DtoOptions.cs
+++ b/MediaBrowser.Controller/Dto/DtoOptions.cs
@@ -82,13 +82,6 @@ namespace MediaBrowser.Controller.Dto
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>
/// <param name="field">The field to check.</param>
diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
index f47d2162f7..0cdc8bce03 100644
--- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
+++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
@@ -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>
diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs
index dab15eb018..47df09d1ce 100644
--- a/MediaBrowser.Controller/Entities/IHasStartDate.cs
+++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs
@@ -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; }
}
}
diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs
index 4928bda7a2..756dbecb98 100644
--- a/MediaBrowser.Controller/Entities/IItemByName.cs
+++ b/MediaBrowser.Controller/Entities/IItemByName.cs
@@ -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; }
}
}
diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
index cdda8ea399..0f8904df5c 100644
--- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
+++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
@@ -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>
diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs
index be19e1bdae..97aa22dc04 100644
--- a/MediaBrowser.Controller/Entities/SourceType.cs
+++ b/MediaBrowser.Controller/Entities/SourceType.cs
@@ -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
}
}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 847f4cf187..1b0bbe9ea0 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -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);
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index 68d6d215b2..1f84b46a2b 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -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
}
})
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 66bf6ebd24..0ddd378352 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -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(
diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
index bd13437fb6..7566616f70 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
@@ -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.
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
index d060b247da..d75eea5904 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
@@ -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 />
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 5301f52e01..c568a74b01 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -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;
-
- 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;
+ var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
- 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);
- }
- catch (FileNotFoundException)
- {
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
+ standardError = await standardErrorTask.ConfigureAwait(false);
}
- }
- 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)
+ catch (OperationCanceledException)
{
- _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>
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index defd855ec0..78bb881ec2 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -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,
diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs
index 017e26ef59..75ccdcf276 100644
--- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs
+++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs
@@ -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; }
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
index b4e3fd3089..6266413dfc 100644
--- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
@@ -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) };
-
- book.AddPerson(person);
+ 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}";
+ }
+
+ return InitialsRegex().Replace(fullName, ". ");
+ });
+
+ foreach (var fullName in normalizedCreators)
+ {
+ book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) });
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 118ccf8679..60eda1628c 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -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
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index b2f9d13e73..9bb15ca479 100755
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -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))
diff --git a/README.md b/README.md
index 5e066f3d31..f3a5a2a4ef 100644
--- a/README.md
+++ b/README.md
@@ -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>
---
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index b8545cbb64..4e1b62cdf9 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -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();
diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
index d877a0d124..19c4514766 100644
--- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
@@ -83,6 +83,7 @@ namespace Jellyfin.LiveTv.IO
CreateNoWindow = true,
UseShellExecute = false,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index d456bea469..c93d1f039c 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -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;
}
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
index 7088d1f0bf..0aeb7ad05d 100644
--- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
@@ -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)
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs
new file mode 100644
index 0000000000..b5ddb1530f
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs
@@ -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++;
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
index cbe97a8210..af868e4bd6 100644
--- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
+++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
@@ -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,
diff --git a/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs
new file mode 100644
index 0000000000..a248664928
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs
@@ -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);
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
new file mode 100644
index 0000000000..f040a328bb
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
@@ -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);
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index b71dc15201..f698edc637 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -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);
}
}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs
new file mode 100644
index 0000000000..b8d1c60e1a
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs
@@ -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),
+ };
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
index 59cd42c05b..1bc42d5fe5 100644
--- a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
@@ -176,6 +176,30 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect
}
/// <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>
[Fact]
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml
new file mode 100644
index 0000000000..15f85f57e6
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml
@@ -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>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml
new file mode 100644
index 0000000000..2b49c3bccd
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml
@@ -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>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml
new file mode 100644
index 0000000000..090273ac98
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml
@@ -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>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml
new file mode 100644
index 0000000000..532b91da20
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml
@@ -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>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml
new file mode 100644
index 0000000000..db0d5e86de
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml
@@ -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>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml
new file mode 100644
index 0000000000..168c0a643b
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml
@@ -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>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml
new file mode 100644
index 0000000000..73288e7c57
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml
@@ -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>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml
new file mode 100644
index 0000000000..d0ff1b82f5
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml
@@ -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>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json
new file mode 100644
index 0000000000..34931aa769
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json
@@ -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."}]
diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
index 988073074b..bfe6ade1fe 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
@@ -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);
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
index 1f2d618aa4..604b862fbe 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
@@ -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
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
index 1f908d7e0e..b03651e5e9 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
@@ -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);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
index b7152961cd..01a35e6cb0 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
@@ -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);
-
- 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 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);
+ Assert.Equal(2, parsed.Paragraphs.Count);
+
+ 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 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);
-
- 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 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);
+ Assert.Equal(2, parsed.Paragraphs.Count);
+
+ 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 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);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
index 5b7aa7eaa9..d814088593 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
@@ -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);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
index ce1f005f40..2d0fa29c9a 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
@@ -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();
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index a5de0a4416..9c247d54b9 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -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)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
index c8aa14af58..b7fca74310 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -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)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index b788fb304e..c80f899498 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -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]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
index 650d67b195..e65bc1d31f 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
@@ -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));
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
new file mode 100644
index 0000000000..bd14ca008d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -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);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
new file mode 100644
index 0000000000..cb714a4014
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
@@ -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;
+ }
+ }
+}