aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTORS.md3
-rw-r--r--Emby.Naming/Common/NamingOptions.cs6
-rw-r--r--Emby.Naming/TV/SeriesPathParser.cs20
-rw-r--r--Emby.Server.Implementations/Chapters/ChapterManager.cs9
-rw-r--r--Emby.Server.Implementations/Data/ItemTypeLookup.cs2
-rw-r--r--Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs35
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs27
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs50
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs50
-rw-r--r--Emby.Server.Implementations/Localization/Core/bs.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json33
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json3
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs42
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/0-prefer.json2
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ar.json24
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/au.json2
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/de.json10
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/fr.json8
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/id.json25
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/tw.json10
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs61
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs8
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs12
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs9
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs4
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260915104305_FixNullEncoderPreset.cs77
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs (renamed from Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs)2
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs2
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs11
-rw-r--r--MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs8
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs160
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs7
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs17
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs30
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs37
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs72
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs9
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs55
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs81
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs137
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs7
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs11
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs60
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs10
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs136
-rw-r--r--tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs35
-rw-r--r--tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs107
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs25
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs26
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs134
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs74
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs173
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs158
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs157
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs68
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs89
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs100
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs2
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs118
67 files changed, 2457 insertions, 233 deletions
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index f5f844bcbc..0b36dfa529 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -242,6 +242,9 @@
- [m0g3r](https://github.com/m0g3r)
- [martin-77](https://github.com/martin-77)
- [Oggeb1](https://github.com/Oggeb1)
+ - [scr4bble](https://github.com/scr4bble)
+ - [Mateus Bandeira](https://github.com/mateusbandeiraa)
+ - [TheDreadPirate](https://github.com/thedreaddpirate)
# Emby Contributors
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs
index 9ecefb4c7b..58d6e012f9 100644
--- a/Emby.Naming/Common/NamingOptions.cs
+++ b/Emby.Naming/Common/NamingOptions.cs
@@ -543,6 +543,12 @@ namespace Emby.Naming.Common
MediaType.Video),
new ExtraRule(
+ ExtraType.Sample,
+ ExtraRuleType.DirectoryName,
+ "sample",
+ MediaType.Video),
+
+ new ExtraRule(
ExtraType.Short,
ExtraRuleType.DirectoryName,
"shorts",
diff --git a/Emby.Naming/TV/SeriesPathParser.cs b/Emby.Naming/TV/SeriesPathParser.cs
index 94b4b5c823..1dd7a55aaf 100644
--- a/Emby.Naming/TV/SeriesPathParser.cs
+++ b/Emby.Naming/TV/SeriesPathParser.cs
@@ -20,6 +20,14 @@ namespace Emby.Naming.TV
foreach (var expression in options.EpisodeExpressions)
{
+ // Optimistic expressions (bare numbers, "01.blah", etc.) are only meant for
+ // episode parsing and produce false series names on release folder names like
+ // "Silo.S03.1080p.WEB-DL..." (e.g. reading "264" as S02E64). Skip them here.
+ if (expression.IsOptimistic)
+ {
+ continue;
+ }
+
var currentResult = Parse(path, expression);
if (currentResult.Success)
{
@@ -49,8 +57,18 @@ namespace Emby.Naming.TV
{
if (expression.IsNamed)
{
+ // Reject implausible season numbers (e.g. resolutions like 1280x720
+ // read as S1280E720), mirroring EpisodePathParser.
+ var seasonNumberGroup = match.Groups["seasonnumber"];
+ if (seasonNumberGroup.Success
+ && int.TryParse(seasonNumberGroup.ValueSpan, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var seasonNumber)
+ && ((seasonNumber >= 200 && seasonNumber < 1928) || seasonNumber > 2500))
+ {
+ return result;
+ }
+
result.SeriesName = match.Groups["seriesname"].Value;
- result.Success = !string.IsNullOrEmpty(result.SeriesName) && !match.Groups["seasonnumber"].ValueSpan.IsEmpty;
+ result.Success = !string.IsNullOrEmpty(result.SeriesName) && !seasonNumberGroup.ValueSpan.IsEmpty;
}
}
diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs
index 69cbe533c6..2938170d1b 100644
--- a/Emby.Server.Implementations/Chapters/ChapterManager.cs
+++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs
@@ -137,6 +137,7 @@ public class ChapterManager : IChapterManager
var success = true;
var changesMade = false;
+ var extractedCount = 0;
var runtimeTicks = video.RunTimeTicks ?? 0;
@@ -178,7 +179,7 @@ public class ChapterManager : IChapterManager
Protocol = video.PathProtocol ?? MediaProtocol.File,
};
- _logger.LogInformation("Extracting chapter image for {Name} at {Path}", video.Name, inputPath);
+ _logger.LogDebug("Extracting chapter image for {Name} at {Time}", video.Name, time);
var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
File.Copy(tempFile, path, true);
@@ -194,6 +195,7 @@ public class ChapterManager : IChapterManager
chapter.ImagePath = path;
chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
changesMade = true;
+ extractedCount++;
}
catch (Exception ex)
{
@@ -222,6 +224,11 @@ public class ChapterManager : IChapterManager
}
}
+ if (extractedCount > 0)
+ {
+ _logger.LogInformation("Extracted {Count} chapter images for {Name} at {Path}", extractedCount, video.Name, video.Path);
+ }
+
if (saveChapters && changesMade)
{
SaveChapters(video, chapters);
diff --git a/Emby.Server.Implementations/Data/ItemTypeLookup.cs b/Emby.Server.Implementations/Data/ItemTypeLookup.cs
index 82c0a8b6c5..03117f6de5 100644
--- a/Emby.Server.Implementations/Data/ItemTypeLookup.cs
+++ b/Emby.Server.Implementations/Data/ItemTypeLookup.cs
@@ -1,8 +1,8 @@
using System.Collections.Frozen;
using System.Collections.Generic;
-using System.Threading.Channels;
using Emby.Server.Implementations.Playlists;
using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
diff --git a/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs b/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs
new file mode 100644
index 0000000000..be1fe660c9
--- /dev/null
+++ b/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using MediaBrowser.Controller.Library;
+
+namespace Emby.Server.Implementations.Data;
+
+/// <summary>
+/// Refreshes the database statistics after every library scan.
+/// </summary>
+/// <remarks>
+/// The scheduled optimization skips itself while a scan runs, so without this the first scan of a new server
+/// leaves every query planned for an empty library until the next scheduled run.
+/// </remarks>
+public class RefreshDatabaseStatisticsPostScanTask : ILibraryPostScanTask
+{
+ private readonly IJellyfinDatabaseProvider _databaseProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshDatabaseStatisticsPostScanTask"/> class.
+ /// </summary>
+ /// <param name="databaseProvider">The database provider.</param>
+ public RefreshDatabaseStatisticsPostScanTask(IJellyfinDatabaseProvider databaseProvider)
+ {
+ _databaseProvider = databaseProvider;
+ }
+
+ /// <inheritdoc />
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ await _databaseProvider.RefreshStatistics(cancellationToken).ConfigureAwait(false);
+ progress.Report(100);
+ }
+}
diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index 7cae2a671b..1d3cc3d383 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
@@ -12,6 +13,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
@@ -20,8 +22,11 @@ namespace Emby.Server.Implementations.Images
{
public class CollectionFolderImageProvider : BaseDynamicImageProvider<CollectionFolder>
{
- public CollectionFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ private readonly ILibraryManager _libraryManager;
+
+ public CollectionFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
{
+ _libraryManager = libraryManager;
}
protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item)
@@ -33,8 +38,11 @@ namespace Emby.Server.Implementations.Images
if (viewType == CollectionType.music)
{
- // Music albums usually don't have dedicated backdrops, so use artist instead
- includeItemTypes = [BaseItemKind.MusicArtist];
+ // Music albums usually don't have dedicated backdrops, so use artist instead.
+ // Artists carry no library of their own, so an item query for them is not
+ // restricted to this library and would collage the artists of every music
+ // library. Resolve them through the tracks that credit them instead.
+ return GetArtistsWithImages(view);
}
return view.GetItemList(new InternalItemsQuery
@@ -49,6 +57,19 @@ namespace Emby.Server.Implementations.Images
});
}
+ private IReadOnlyList<BaseItem> GetArtistsWithImages(CollectionFolder view)
+ {
+ return _libraryManager.GetAllArtists(new InternalItemsQuery
+ {
+ AncestorIds = [view.Id],
+ DtoOptions = new DtoOptions(false),
+ EnableTotalRecordCount = false,
+ ImageTypes = [ImageType.Primary],
+ Limit = 8,
+ OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
+ }).Items.Select(i => i.Item).ToArray();
+ }
+
protected override bool Supports(BaseItem item)
{
return item is CollectionFolder;
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index caba304888..6f3df084c0 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1354,7 +1354,6 @@ namespace Emby.Server.Implementations.Library
{
IncludeItemTypes = [BaseItemKind.MusicArtist],
Name = name,
- UseRawName = true,
DtoOptions = options
}).Cast<MusicArtist>()
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
@@ -3467,6 +3466,7 @@ namespace Emby.Server.Implementations.Library
var extras = new List<BaseItem>();
var typeCounters = new Dictionary<ExtraType, int>();
+ var generatedNames = new Dictionary<ExtraType, HashSet<string>>();
// Order by path so that the numbering handed out below does not depend on the
// order the file system happened to list the folder in
@@ -3503,10 +3503,12 @@ namespace Emby.Server.Implementations.Library
extra = itemById;
}
- // An extra is named after its file, so the file is the source of truth. Items created
- // by older versions, or renamed by a metadata provider, are corrected here;
- // RefreshExtras persists the change.
- if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true)
+ // The name derived from the file is only a default. A name that came from anywhere else,
+ // such as a local metadata file, is the user's and has to survive the scan, so only a
+ // name this method handed out itself is renewed; RefreshExtras persists the change.
+ if (!string.IsNullOrEmpty(name)
+ && extra.LockedFields?.Contains(MetadataField.Name) != true
+ && (itemById is null || IsGeneratedExtraName(extra.Name, candidate)))
{
extra.Name = name;
}
@@ -3528,6 +3530,31 @@ namespace Emby.Server.Implementations.Library
return null;
}
+
+ bool IsGeneratedExtraName(string currentName, ExtraCandidate candidate)
+ {
+ // The file name is what an extra was called before it was given a name of its type
+ if (string.Equals(currentName, candidate.Extra.Name, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (!generatedNames.TryGetValue(candidate.ExtraType, out var names))
+ {
+ // Any of the numbers of this type may have been handed out, as the order the extras
+ // of a type are numbered in shifts as files appear beside them or are taken away
+ names = new HashSet<string>(StringComparer.Ordinal);
+ var count = candidates.Count(c => c.ExtraType == candidate.ExtraType);
+ for (var seen = 0; seen < count; seen++)
+ {
+ names.Add(GetNumberedExtraName(candidate.ExtraType, seen));
+ }
+
+ generatedNames[candidate.ExtraType] = names;
+ }
+
+ return names.Contains(currentName);
+ }
}
/// <summary>
@@ -3554,7 +3581,18 @@ namespace Emby.Server.Implementations.Library
typeCounters.TryGetValue(candidate.ExtraType, out var seen);
typeCounters[candidate.ExtraType] = seen + 1;
- var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType));
+ return GetNumberedExtraName(candidate.ExtraType, seen);
+ }
+
+ /// <summary>
+ /// Gets the name given to the n-th extra of a type that is named after its type.
+ /// </summary>
+ /// <param name="extraType">The extra type.</param>
+ /// <param name="seen">Number of extras of the type named before this one.</param>
+ /// <returns>The name.</returns>
+ private string GetNumberedExtraName(ExtraType extraType, int seen)
+ {
+ var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(extraType));
return seen == 0
? typeName
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index cfb2dd53d3..6d52ce7440 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -60,10 +60,16 @@ namespace Emby.Server.Implementations.Library
var folderViewType = collectionFolder?.CollectionType;
// Playlist and BoxSet libraries require special handling because the folder only references linked items
- if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
- && !HasVisibleChild(folder, user))
+ if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
{
- continue;
+ var itemKind = folderViewType == CollectionType.playlists
+ ? BaseItemKind.Playlist
+ : BaseItemKind.BoxSet;
+
+ if (!HasVisibleItem(itemKind, folders, user))
+ {
+ continue;
+ }
}
if (UserView.IsUserSpecific(folder))
@@ -152,30 +158,30 @@ namespace Emby.Server.Implementations.Library
.ToArray();
}
- private bool HasVisibleChild(Folder folder, User user)
+ private bool HasVisibleItem(BaseItemKind itemKind, IReadOnlyList<Folder> folders, User user)
{
- // Folder.Children answers this too, but a collection folder delegates it to its physical
- // folders, which resolve and then hold on to every child with every field.
- var parentIds = folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0
- ? collectionFolder.PhysicalFolderIds
- : [folder.Id];
+ var topParentIds = folders.SelectMany(GetTopParentIds).ToArray();
+ if (topParentIds.Length == 0)
+ {
+ return false;
+ }
- foreach (var parentId in parentIds)
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
- var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
- {
- ParentId = parentId,
- GroupByPresentationUniqueKey = false,
- DtoOptions = DtoOptions.StoredColumnsOnly
- });
+ IncludeItemTypes = [itemKind],
+ TopParentIds = topParentIds,
+ GroupByPresentationUniqueKey = false,
+ DtoOptions = DtoOptions.StoredColumnsOnly
+ });
- if (items.Any(item => item.IsVisible(user)))
- {
- return true;
- }
- }
+ return items.Any(item => item.IsVisible(user));
+ }
- return false;
+ private static IEnumerable<Guid> GetTopParentIds(Folder folder)
+ {
+ return folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0
+ ? collectionFolder.PhysicalFolderIds
+ : [folder.Id];
}
public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName)
diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json
index 5686807d9a..98dcc68ed1 100644
--- a/Emby.Server.Implementations/Localization/Core/bs.json
+++ b/Emby.Server.Implementations/Localization/Core/bs.json
@@ -118,5 +118,8 @@
"NameExtraShort": "Kratko",
"NameExtraThemeSong": "Tema",
"NameExtraThemeVideo": "Tematski video",
- "NameExtraTrailer": "Najava"
+ "NameExtraTrailer": "Najava",
+ "LyricDownloadFailureFromForItem": "Tekstovi nisu uspješno preuzeti sa {0} za {1}",
+ "NameExtraUnknown": "Ekstra",
+ "Original": "Original"
}
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 6e5532ead9..4c7d16a9a7 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -10,7 +10,7 @@
"Folders": "Mapper",
"Genres": "Genrer",
"HeaderContinueWatching": "Fortsæt med at se",
- "HeaderFavoriteEpisodes": "Yndlingsafsnit",
+ "HeaderFavoriteEpisodes": "Favoritepisoder",
"HeaderFavoriteShows": "Yndlingsserier",
"HeaderLiveTV": "Live-TV",
"HeaderNextUp": "Næste",
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index d44f0cc68d..080fa69524 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -25,7 +25,7 @@
"NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
"NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
"NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
- "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
+ "NotificationOptionPluginUninstalled": "Ískoytisforrit er strikað",
"NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
"NotificationOptionUserLockedOut": "Brúkari útihýstur",
"Photos": "Ljósmyndir",
@@ -69,7 +69,7 @@
"TasksApplicationCategory": "Nýtsluskipan",
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd",
- "UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
+ "UserStoppedPlayingItemWithValues": "{0} gavst at spæla {1} á {2}",
"HomeVideos": "Heimaupptøkur",
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
"UserOfflineFromDevice": "{0} breyt av frá {1}",
@@ -96,9 +96,9 @@
"TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.",
"TaskRefreshChapterImages": "Kapitlamyndaúttøkur",
"NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað",
- "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað",
+ "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl er steðgað",
"NotificationOptionAudioPlayback": "Ljóðspæl byrjað",
- "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað",
+ "NotificationOptionAudioPlaybackStopped": "Ljóðspæl er steðgað",
"TaskExtractMediaSegments": "Leita eftir margmiðlabrotum",
"TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.",
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index 78b7ec744b..24008575d4 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -2,18 +2,18 @@
"AppDeviceValues": "アプリ: {0}, デバイス: {1}",
"Artists": "アーティスト",
"AuthenticationSucceededWithUserName": "{0} 認証に成功しました",
- "Books": "ブック",
+ "Books": "書籍",
"ChapterNameValue": "チャプター {0}",
"Collections": "コレクション",
"FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました",
"Favorites": "お気に入り",
"Folders": "フォルダー",
"Genres": "ジャンル",
- "HeaderContinueWatching": "再生を続ける",
+ "HeaderContinueWatching": "視聴をつづける",
"HeaderFavoriteEpisodes": "お気に入りのエピソード",
"HeaderFavoriteShows": "お気に入りの番組",
"HeaderLiveTV": "ライブTV",
- "HeaderNextUp": "次",
+ "HeaderNextUp": "次に再生",
"HomeVideos": "ホームビデオ",
"Inherit": "継承",
"LabelIpAddressValue": "IPアドレス: {0}",
@@ -41,7 +41,7 @@
"NotificationOptionServerRestartRequired": "サーバーを再起動してください",
"NotificationOptionTaskFailed": "スケジュールされていたタスクの失敗",
"NotificationOptionUserLockedOut": "ユーザーはロックされています",
- "NotificationOptionVideoPlayback": "ビデオの再生を開始",
+ "NotificationOptionVideoPlayback": "動画の再生を開始",
"NotificationOptionVideoPlaybackStopped": "ビデオの再生を停止",
"Photos": "フォト",
"PluginInstalledWithName": "{0} をインストールしました",
@@ -80,10 +80,10 @@
"TaskUpdatePlugins": "プラグインの更新",
"TaskRefreshPeopleDescription": "メディアライブラリー内の俳優や監督のメタデータを更新します。",
"TaskRefreshPeople": "俳優や監督のデータの更新",
- "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索する。",
- "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。",
+ "TaskDownloadMissingSubtitlesDescription": "メタデータ設定をもとに、欠落している字幕をインターネットで検索します。",
+ "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネールを作成します。",
"TaskRefreshChapterImages": "チャプター画像を抽出する",
- "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする",
+ "TaskDownloadMissingSubtitles": "欠けている字幕をダウンロード",
"TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。",
"TaskCleanActivityLog": "アクティビティの履歴を消去",
"Undefined": "未定義",
@@ -91,7 +91,7 @@
"Default": "デフォルト",
"TaskOptimizeDatabaseDescription": "データベースをコンパクトにして、空き領域を切り詰めます。メディアライブラリーのスキャンやその他のデータベースの更新を伴う変更の後でこのタスクを実行すると、パフォーマンスが向上します。",
"TaskOptimizeDatabase": "データベースの最適化",
- "TaskKeyframeExtractorDescription": "より正確なHLSプレイリストを作成するため、動画ファイルからキーフレームを抽出する。この処理には時間がかかる場合があります。",
+ "TaskKeyframeExtractorDescription": "動画ファイルからキーフレームを抽出し、より正確な HLS プレーリストを作成します。この処理には時間がかかる場合があります。",
"TaskKeyframeExtractor": "キーフレーム抽出",
"External": "外部",
"HearingImpaired": "聴覚障害の方",
@@ -103,10 +103,23 @@
"TaskExtractMediaSegments": "メディアセグメントを読み取る",
"TaskMoveTrickplayImages": "Trickplayの画像を移動",
"TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。",
- "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
+ "TaskDownloadMissingLyrics": "欠けている歌詞をダウンロード",
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。",
"LyricDownloadFailureFromForItem": "歌詞",
- "Original": "オリジナル"
+ "Original": "オリジナル",
+ "NameExtraThemeVideo": "テーマビデオ",
+ "NameExtraBehindTheScenes": "舞台裏",
+ "NameExtraClip": "クリップ",
+ "NameExtraDeletedScene": "未公開シーン",
+ "NameExtraFeaturette": "短編",
+ "NameExtraInterview": "インタビュー",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "サンプル",
+ "NameExtraScene": "シーン",
+ "NameExtraShort": "ショート",
+ "NameExtraThemeSong": "テーマ曲",
+ "NameExtraTrailer": "予告編",
+ "NameExtraUnknown": "特典"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index dccec8067d..6baaeca524 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -120,5 +120,6 @@
"NameExtraShort": "Scurt",
"NameExtraThemeSong": "Audio de Fundal",
"NameExtraThemeVideo": "Video de Fundal",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 3f89237ab2..7af545c550 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -398,6 +398,7 @@ namespace Emby.Server.Implementations.Localization
return null;
}
+ var hasUnknownRating = false;
foreach (var ratingValue in ratingValues)
{
// A single entry of such a list may be unrated while a later one still resolves
@@ -406,11 +407,21 @@ namespace Emby.Server.Implementations.Localization
continue;
}
- var score = GetSingleRatingScore(ratingValue, countryCode);
+ // An unknown entry is not worth a warning of its own as long as another entry still resolves
+ var score = GetSingleRatingScore(ratingValue, countryCode, logUnknownRating: false);
if (score is not null)
{
return score;
}
+
+ hasUnknownRating = true;
+ }
+
+ if (hasUnknownRating)
+ {
+ _logger.LogWarning(
+ "None of the ratings in '{Rating}' were found in a known rating system, treating as unrated",
+ rating);
}
return null;
@@ -427,7 +438,7 @@ namespace Emby.Server.Implementations.Localization
/// <summary>
/// Resolves a single rating value to a score.
/// </summary>
- private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode)
+ private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode, bool logUnknownRating = true)
{
// Convert ints directly
// This may override some of the locale specific age ratings (but those always map to the same age)
@@ -483,9 +494,11 @@ namespace Emby.Server.Implementations.Localization
}
}
- // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18"
- if (TryGetRatingScoreBySeparator(rating, ':', out var result)
- || TryGetRatingScoreBySeparator(rating, '-', out result))
+ // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18".
+ // A '/' marks a list of ratings, which this would split into the first entry's country and the last entry's rating
+ if (!rating.Contains('/', StringComparison.Ordinal)
+ && (TryGetRatingScoreBySeparator(rating, ':', logUnknownRating, out var result)
+ || TryGetRatingScoreBySeparator(rating, '-', logUnknownRating, out result)))
{
return result;
}
@@ -493,7 +506,7 @@ namespace Emby.Server.Implementations.Localization
return null;
}
- private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
+ private bool TryGetRatingScoreBySeparator(string rating, char separator, bool logUnknownRating, out ParentalRatingScore? result)
{
result = null;
@@ -546,10 +559,13 @@ namespace Emby.Server.Implementations.Localization
return true;
}
- _logger.LogWarning(
- "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
- rating,
- resolvedCountryCode);
+ if (logUnknownRating)
+ {
+ _logger.LogWarning(
+ "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
+ rating,
+ resolvedCountryCode);
+ }
return true;
}
@@ -561,14 +577,16 @@ namespace Emby.Server.Implementations.Localization
}
/// <summary>
- /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
+ /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+")
+ /// or a leading '-' (e.g. the French "-12").
/// </summary>
/// <param name="ratingValue">Rating value to parse.</param>
/// <param name="score">Parsed score.</param>
/// <returns>Returns true if parsing was successful.</returns>
private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
{
- var trimmed = ratingValue.TrimEnd('+');
+ // A leading '-' marks a minimum age ("-12" is French for "not for under 12s"), never a negative score
+ var trimmed = ratingValue.TrimStart('-').TrimEnd('+');
return int.TryParse(trimmed, out score);
}
diff --git a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json
index b390151611..dd88b35363 100644
--- a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json
+++ b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json
@@ -24,7 +24,7 @@
}
},
{
- "ratingStrings": ["X", "XX", "XXX", "XXXX"],
+ "ratingStrings": ["X", "XX", "XXX", "XXXX", "P"],
"ratingScore": {
"score": 1000,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/ar.json b/Emby.Server.Implementations/Localization/Ratings/ar.json
index 73dfd2c7c9..20053fe575 100644
--- a/Emby.Server.Implementations/Localization/Ratings/ar.json
+++ b/Emby.Server.Implementations/Localization/Ratings/ar.json
@@ -3,35 +3,49 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["ATP"],
+ "ratingStrings": ["ATP", "Apta para todo público", "G"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["+13"],
+ "ratingStrings": ["SP"],
+ "ratingScore": {
+ "score": 10,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["+13", "SAM 13", "SAM13", "R-13"],
"ratingScore": {
"score": 13,
"subScore": null
}
},
{
- "ratingStrings": ["+16"],
+ "ratingStrings": ["+16", "SAM 16", "SAM16"],
"ratingScore": {
"score": 16,
"subScore": null
}
},
{
- "ratingStrings": ["+18"],
+ "ratingStrings": ["R-17"],
+ "ratingScore": {
+ "score": 17,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["+18", "SAM 18", "SAM18"],
"ratingScore": {
"score": 18,
"subScore": null
}
},
{
- "ratingStrings": ["C"],
+ "ratingStrings": ["C", "+18 C", "SAM 18 C", "SAM 18C", "SAM18C"],
"ratingScore": {
"score": 1001,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/au.json b/Emby.Server.Implementations/Localization/Ratings/au.json
index a563df899d..c5d4c97443 100644
--- a/Emby.Server.Implementations/Localization/Ratings/au.json
+++ b/Emby.Server.Implementations/Localization/Ratings/au.json
@@ -52,7 +52,7 @@
}
},
{
- "ratingStrings": ["X", "X18", "X 18"],
+ "ratingStrings": ["X", "X18", "X 18", "X18+", "X 18+"],
"ratingScore": {
"score": 1000,
"subScore": 0
diff --git a/Emby.Server.Implementations/Localization/Ratings/de.json b/Emby.Server.Implementations/Localization/Ratings/de.json
index 30c34b230c..2a925f999f 100644
--- a/Emby.Server.Implementations/Localization/Ratings/de.json
+++ b/Emby.Server.Implementations/Localization/Ratings/de.json
@@ -3,35 +3,35 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["0", "FSK 0", "FSK-0", "Educational", "Infoprogramm"],
+ "ratingStrings": ["0", "FSK 0", "FSK-0", "FSK0", "ab 0", "Educational", "Infoprogramm"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["6", "FSK 6", "FSK-6"],
+ "ratingStrings": ["6", "FSK 6", "FSK-6", "FSK6", "ab 6"],
"ratingScore": {
"score": 6,
"subScore": null
}
},
{
- "ratingStrings": ["12", "FSK 12", "FSK-12"],
+ "ratingStrings": ["12", "FSK 12", "FSK-12", "FSK12", "ab 12"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
- "ratingStrings": ["16", "FSK 16", "FSK-16"],
+ "ratingStrings": ["16", "FSK 16", "FSK-16", "FSK16", "ab 16"],
"ratingScore": {
"score": 16,
"subScore": null
}
},
{
- "ratingStrings": ["18", "FSK 18", "FSK-18"],
+ "ratingStrings": ["18", "FSK 18", "FSK-18", "FSK18", "ab 18"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.json b/Emby.Server.Implementations/Localization/Ratings/fr.json
index e8bafd6b87..333e467a08 100644
--- a/Emby.Server.Implementations/Localization/Ratings/fr.json
+++ b/Emby.Server.Implementations/Localization/Ratings/fr.json
@@ -24,14 +24,14 @@
}
},
{
- "ratingStrings": ["10"],
+ "ratingStrings": ["10", "Interdit aux moins de 10 ans"],
"ratingScore": {
"score": 10,
"subScore": null
}
},
{
- "ratingStrings": ["12"],
+ "ratingStrings": ["12", "–12", "Interdit aux moins de 12 ans"],
"ratingScore": {
"score": 12,
"subScore": null
@@ -45,14 +45,14 @@
}
},
{
- "ratingStrings": ["16"],
+ "ratingStrings": ["16", "–16", "Interdit aux moins de 16 ans"],
"ratingScore": {
"score": 16,
"subScore": null
}
},
{
- "ratingStrings": ["18"],
+ "ratingStrings": ["18", "–18", "Interdit aux moins de 18 ans"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/id.json b/Emby.Server.Implementations/Localization/Ratings/id.json
index 8c687c2320..f55e4e4333 100644
--- a/Emby.Server.Implementations/Localization/Ratings/id.json
+++ b/Emby.Server.Implementations/Localization/Ratings/id.json
@@ -3,14 +3,28 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["SU"],
+ "ratingStrings": ["SU", "SU-BO", "Semua Umur"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["13+"],
+ "ratingStrings": ["P", "P2+", "P-BO", "Prasekolah"],
+ "ratingScore": {
+ "score": 2,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["A", "A7+", "A-BO", "Anak"],
+ "ratingScore": {
+ "score": 7,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["13+", "R", "R13+", "R-BO", "Remaja"],
"ratingScore": {
"score": 13,
"subScore": null
@@ -24,6 +38,13 @@
}
},
{
+ "ratingStrings": ["D", "D18+", "Dewasa"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
"ratingStrings": ["21+"],
"ratingScore": {
"score": 21,
diff --git a/Emby.Server.Implementations/Localization/Ratings/tw.json b/Emby.Server.Implementations/Localization/Ratings/tw.json
index a7869c1221..9af7dd3128 100644
--- a/Emby.Server.Implementations/Localization/Ratings/tw.json
+++ b/Emby.Server.Implementations/Localization/Ratings/tw.json
@@ -3,35 +3,35 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["0+"],
+ "ratingStrings": ["0+", "普遍級", "普"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["6+"],
+ "ratingStrings": ["6+", "保護級", "護"],
"ratingScore": {
"score": 6,
"subScore": null
}
},
{
- "ratingStrings": ["12+"],
+ "ratingStrings": ["12+", "輔12級", "輔導12歲級", "輔導級"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
- "ratingStrings": ["15+"],
+ "ratingStrings": ["15+", "輔15級", "輔導15歲級"],
"ratingScore": {
"score": 15,
"subScore": null
}
},
{
- "ratingStrings": ["18+"],
+ "ratingStrings": ["18+", "限制級", "限"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index c4851091c1..6721cd6c7e 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -233,18 +233,17 @@ public class SubtitleController : BaseJellyfinApiController
format = "json";
}
- if (string.IsNullOrEmpty(format))
+ // Graphical subtitles cannot be parsed or rewritten, so they are always delivered as the raw
+ // stream for the client to render. Serve them straight from disk instead of piping them
+ // through the encoder, so that range requests keep working: clients fetch these tracks
+ // incrementally while rendering them, and they are far too large to hand over in one piece.
+ if (MediaStream.IsPgsFormat(format) || MediaStream.IsVobSubFormat(format))
{
- var item = _libraryManager.GetItemById<Video>(itemId.Value);
-
- var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture);
- var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false)
- .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal));
-
- var subtitleStream = mediaSource.MediaStreams
- .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
-
- return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path));
+ var subtitlePath = await GetGraphicalSubtitlePath(itemId.Value, mediaSourceId, index.Value).ConfigureAwait(false);
+ if (!string.IsNullOrEmpty(subtitlePath))
+ {
+ return PhysicalFile(subtitlePath, MimeTypes.GetMimeType(subtitlePath), true);
+ }
}
if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
@@ -456,6 +455,46 @@ public class SubtitleController : BaseJellyfinApiController
}
}
+ private async Task<string?> GetGraphicalSubtitlePath(Guid id, string? mediaSourceId, int index)
+ {
+ var item = _libraryManager.GetItemById<BaseItem>(id);
+ if (item is null)
+ {
+ return null;
+ }
+
+ var idString = id.ToString("N", CultureInfo.InvariantCulture);
+ var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false)
+ .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.OrdinalIgnoreCase));
+
+ var subtitleStream = mediaSource?.MediaStreams
+ .FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
+
+ if (mediaSource is null || subtitleStream is null
+ || !(subtitleStream.IsPgsSubtitleStream || subtitleStream.IsVobSubSubtitleStream))
+ {
+ return null;
+ }
+
+ var path = await _subtitleEncoder.GetSubtitleFilePath(subtitleStream, mediaSource, CancellationToken.None).ConfigureAwait(false);
+ if (string.IsNullOrEmpty(path))
+ {
+ return null;
+ }
+
+ // Only a raw PGS payload or an extracted .mks can be handed over untouched. Anything else the
+ // encoder resolved to (e.g. a VobSub .idx/.sub pair, which it converts) is not a passthrough.
+ var extension = Path.GetExtension(path.AsSpan()).TrimStart('.').ToString();
+ if (!MediaStream.IsPgsFormat(extension)
+ && !string.Equals(extension, "mks", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ // Remotely hosted external subtitles have no local file to serve; those fall back to the encoder.
+ return _fileSystem.FileExists(path) ? path : null;
+ }
+
/// <summary>
/// Encodes a subtitle in the specified format.
/// </summary>
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 51ac146a6f..4e5d561f0a 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -212,6 +212,7 @@ public sealed partial class BaseItemRepository
IsFavoriteOrLiked = filter.IsFavoriteOrLiked,
IsLiked = filter.IsLiked,
IsLocked = filter.IsLocked,
+ ImageTypes = filter.ImageTypes,
NameLessThan = filter.NameLessThan,
NameStartsWith = filter.NameStartsWith,
NameStartsWithOrGreater = filter.NameStartsWithOrGreater,
@@ -223,7 +224,12 @@ public sealed partial class BaseItemRepository
Years = filter.Years,
NameContains = filter.NameContains,
SearchTerm = filter.SearchTerm,
- ExcludeItemIds = filter.ExcludeItemIds
+ ExcludeItemIds = filter.ExcludeItemIds,
+
+ // A genre, studio or artist carries none of the tags of the media it describes, so an
+ // allow list can only ever hide all of them. Reachability is settled by innerQueryFilter
+ // instead: a value gets this far only when an item the user may see carries it.
+ IncludeInheritedTags = []
};
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index d726f0f143..a7110485b4 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -476,16 +476,8 @@ public sealed partial class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Name))
{
- if (filter.UseRawName == true)
- {
- var nameLower = filter.Name.ToLowerInvariant();
- baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower);
- }
- else
- {
- var cleanName = filter.Name.GetCleanValue();
- baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
- }
+ var cleanName = filter.Name.GetCleanValue();
+ baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
}
var nameContains = filter.NameContains;
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index 58b9f7f822..40a153c8ed 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -11,6 +11,7 @@ using AsyncKeyedLock;
using J2N.Collections.Generic.Extensions;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
@@ -478,6 +479,14 @@ public partial class TrickplayManager : ITrickplayManager
var mediaStream = mediaSource.VideoStream;
var container = mediaSource.Container;
+ // Checks for write permission before generating images
+ if (saveWithMedia)
+ {
+ _logger.LogDebug("Verifying write permission in media directory {OutputDir}", outputDir);
+ Directory.CreateDirectory(outputDir.FullName);
+ FileHelper.CreateEmpty(Path.Combine(outputDir.FullName, ".jellyfin-trickplay"));
+ }
+
_logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWidth, mediaPath, video.Id);
imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
mediaPath,
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
index 0141b43c96..a5006c9bfa 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
@@ -79,8 +79,8 @@ public class MigrateEncodingOptions : IMigrationRoutine
tonemappingRange = parsedTonemappingRange;
}
- var encoderPreset = EncoderPreset.superfast;
- if (Enum.TryParse<EncoderPreset>(oldConfig.TonemappingRange, true, out var parsedEncoderPreset))
+ var encoderPreset = EncoderPreset.auto;
+ if (Enum.TryParse<EncoderPreset>(oldConfig.EncoderPreset, true, out var parsedEncoderPreset))
{
encoderPreset = parsedEncoderPreset;
}
diff --git a/Jellyfin.Server/Migrations/Routines/20260915104305_FixNullEncoderPreset.cs b/Jellyfin.Server/Migrations/Routines/20260915104305_FixNullEncoderPreset.cs
new file mode 100644
index 0000000000..f8e63f1920
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260915104305_FixNullEncoderPreset.cs
@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using Jellyfin.Server.Migrations.Stages;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Replaces the nullable encoder preset written by older versions with the default preset.
+/// </summary>
+[JellyfinMigration("2026-09-15T10:43:05", nameof(FixNullEncoderPreset), Stage = JellyfinMigrationStageTypes.PreInitialisation)]
+internal class FixNullEncoderPreset : IAsyncMigrationRoutine
+{
+ private static readonly XNamespace _xsiNamespace = "http://www.w3.org/2001/XMLSchema-instance";
+
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ILogger<FixNullEncoderPreset> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FixNullEncoderPreset"/> class.
+ /// </summary>
+ /// <param name="applicationPaths">The application paths.</param>
+ /// <param name="logger">The logger.</param>
+ public FixNullEncoderPreset(IApplicationPaths applicationPaths, ILogger<FixNullEncoderPreset> logger)
+ {
+ _applicationPaths = applicationPaths;
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "encoding.xml");
+ if (!File.Exists(path))
+ {
+ return Task.CompletedTask;
+ }
+
+ XDocument document;
+ try
+ {
+ document = XDocument.Load(path, LoadOptions.PreserveWhitespace);
+ }
+ catch (XmlException ex)
+ {
+ _logger.LogWarning(ex, "Could not parse encoding configuration; skipping null encoder preset migration");
+ return Task.CompletedTask;
+ }
+
+ var encoderPreset = document.Root?.Element(nameof(EncodingOptions.EncoderPreset));
+ if (encoderPreset is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ var nilAttribute = encoderPreset.Attribute(_xsiNamespace + "nil");
+ if (!string.Equals(nilAttribute?.Value, "true", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(encoderPreset.Value))
+ {
+ return Task.CompletedTask;
+ }
+
+ nilAttribute?.Remove();
+ encoderPreset.Value = nameof(EncoderPreset.auto);
+ document.Save(path, SaveOptions.DisableFormatting);
+ _logger.LogInformation("Replaced null encoder preset with auto in encoding configuration");
+ return Task.CompletedTask;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs
index a456acf47d..903d91e046 100644
--- a/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs
@@ -11,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines;
/// Migrate rating levels.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
-[JellyfinMigration("2026-09-10T12:00:00", nameof(MigrateRatingLevels))]
+[JellyfinMigration("2026-09-15T12:00:00", nameof(MigrateRatingLevels))]
[JellyfinMigrationBackup(JellyfinDb = true)]
#pragma warning restore CS0618 // Type or member is obsolete
internal class MigrateRatingLevels : IDatabaseMigrationRoutine
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 7c88d5dd05..e0f0e0d476 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -235,8 +235,6 @@ namespace MediaBrowser.Controller.Entities
public string? Name { get; set; }
- public bool? UseRawName { get; set; }
-
public string? Person { get; set; }
public Guid[] PersonIds { get; set; }
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 6f010c0242..58d7495fa4 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -7832,7 +7832,9 @@ namespace MediaBrowser.Controller.MediaEncoding
var channels = state.OutputAudioChannels;
- var useDownMixAlgorithm = state.AudioStream is not null
+ // Must match the condition under which GetAudioFilterParam emits the downmix filter.
+ var useDownMixAlgorithm = channels == 2
+ && state.AudioStream?.Channels > 2
&& DownMixAlgorithmsHelper.AlgorithmFilterStrings.ContainsKey((encodingOptions.DownMixStereoAlgorithm, DownMixAlgorithmsHelper.InferChannelLayout(state.AudioStream)));
if (channels.HasValue && !useDownMixAlgorithm)
@@ -7927,6 +7929,13 @@ namespace MediaBrowser.Controller.MediaEncoding
audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
}
+ // Without the downmix filter, -ac 2 alone drops the LFE channel.
+ var audioFilterParam = GetAudioFilterParam(state, encodingOptions);
+ if (!string.IsNullOrEmpty(audioFilterParam))
+ {
+ audioTranscodeParams.Add(audioFilterParam.TrimStart());
+ }
+
// Copy the movflags from GetProgressiveVideoFullCommandLine
// See #9248 and the associated PR for why this is needed
if (_mp4ContainerNames.Contains(state.OutputContainer))
diff --git a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs
index f631c471f6..17ab378ef7 100644
--- a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs
+++ b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs
@@ -198,10 +198,10 @@ namespace MediaBrowser.MediaEncoding.Probing
public string? CodecTag { get; set; }
/// <summary>
- /// Gets or sets the codec_tag_string?.
+ /// Gets or sets the codec_tag_string.
/// </summary>
- /// <value>The codec_tag_string?.</value>
- [JsonPropertyName("codec_tag_string?")]
+ /// <value>The codec_tag_string.</value>
+ [JsonPropertyName("codec_tag_string")]
public string? CodecTagString { get; set; }
/// <summary>
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
index 7dee5fd31d..55913aa3fc 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
@@ -70,7 +70,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (!string.IsNullOrWhiteSpace(artistMusicBrainzId))
{
- var releaseSearchResults = await query.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
+ var releaseSearchResults = await query.FindReleasesWithRetryAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", _logger, cancellationToken)
.ConfigureAwait(false);
if (releaseSearchResults.Results.Count > 0)
@@ -83,7 +83,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
// I'm sure there is a better way but for now it resolves search for 12" Mixes
var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal);
- var releaseSearchResults = await query.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken)
+ var releaseSearchResults = await query.FindReleasesWithRetryAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", _logger, cancellationToken)
.ConfigureAwait(false);
if (releaseSearchResults.Results.Count > 0)
@@ -200,13 +200,13 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (!string.IsNullOrEmpty(artistMusicBrainzId))
{
- var releaseSearchResults = await query.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
+ var releaseSearchResults = await query.FindReleasesWithRetryAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", _logger, cancellationToken)
.ConfigureAwait(false);
releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null;
}
else if (!string.IsNullOrEmpty(info.GetAlbumArtist()))
{
- var releaseSearchResults = await query.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken)
+ var releaseSearchResults = await query.FindReleasesWithRetryAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", _logger, cancellationToken)
.ConfigureAwait(false);
releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null;
}
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
index c3d13ed42c..eb189f1258 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
@@ -60,7 +60,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
return [];
}
- var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken)
+ var artistSearchResults = await query.FindArtistsWithRetryAsync($"\"{searchInfo.Name}\"", _logger, cancellationToken)
.ConfigureAwait(false);
if (artistSearchResults.Results.Count > 0)
{
@@ -70,7 +70,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
if (searchInfo.Name.HasDiacritics())
{
// Try again using the search with an accented characters query
- var artistAccentsSearchResults = await query.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken)
+ var artistAccentsSearchResults = await query.FindArtistsWithRetryAsync($"artistaccent:\"{searchInfo.Name}\"", _logger, cancellationToken)
.ConfigureAwait(false);
if (artistAccentsSearchResults.Results.Count > 0)
{
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs
index f3df41e942..620a0b3c45 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using MetaBrainz.Common;
using MetaBrainz.MusicBrainz;
using MetaBrainz.MusicBrainz.Interfaces.Entities;
+using MetaBrainz.MusicBrainz.Interfaces.Searches;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
@@ -15,6 +16,15 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz;
internal static class MusicBrainzQueryExtensions
{
/// <summary>
+ /// The number of extra attempts made when MusicBrainz reports a transient failure.
+ /// </summary>
+ private const int MaxRetries = 2;
+
+ private static readonly TimeSpan _minimumRetryDelay = TimeSpan.FromSeconds(1);
+
+ private static readonly TimeSpan _maximumRetryDelay = TimeSpan.FromSeconds(15);
+
+ /// <summary>
/// Parses a MusicBrainz identifier, which may come from user-supplied tags or NFO files and is therefore not
/// guaranteed to be a valid GUID.
/// </summary>
@@ -49,10 +59,11 @@ internal static class MusicBrainzQueryExtensions
/// <returns>The release, or <see langword="null"/> if MusicBrainz does not have it.</returns>
public static Task<IRelease?> LookupReleaseOrNullAsync(this Query query, Guid releaseId, Include include, ILogger logger, CancellationToken cancellationToken)
=> NotFoundAsNullAsync(
- () => query.LookupReleaseAsync(releaseId, include, cancellationToken),
+ token => query.LookupReleaseAsync(releaseId, include, token),
"release",
releaseId,
- logger);
+ logger,
+ cancellationToken);
/// <summary>
/// Looks up a release group, treating an unknown identifier as missing data rather than an error.
@@ -65,10 +76,11 @@ internal static class MusicBrainzQueryExtensions
/// <returns>The release group, or <see langword="null"/> if MusicBrainz does not have it.</returns>
public static Task<IReleaseGroup?> LookupReleaseGroupOrNullAsync(this Query query, Guid releaseGroupId, Include include, ILogger logger, CancellationToken cancellationToken)
=> NotFoundAsNullAsync(
- () => query.LookupReleaseGroupAsync(releaseGroupId, include, null, cancellationToken),
+ token => query.LookupReleaseGroupAsync(releaseGroupId, include, null, token),
"release group",
releaseGroupId,
- logger);
+ logger,
+ cancellationToken);
/// <summary>
/// Looks up an artist, treating an unknown identifier as missing data rather than an error.
@@ -81,10 +93,141 @@ internal static class MusicBrainzQueryExtensions
/// <returns>The artist, or <see langword="null"/> if MusicBrainz does not have it.</returns>
public static Task<IArtist?> LookupArtistOrNullAsync(this Query query, Guid artistId, Include include, ILogger logger, CancellationToken cancellationToken)
=> NotFoundAsNullAsync(
- () => query.LookupArtistAsync(artistId, include, null, null, cancellationToken),
+ token => query.LookupArtistAsync(artistId, include, null, null, token),
"artist",
artistId,
- logger);
+ logger,
+ cancellationToken);
+
+ /// <summary>
+ /// Searches for artists, retrying when the MusicBrainz server is too busy to answer.
+ /// </summary>
+ /// <param name="query">The MusicBrainz query client.</param>
+ /// <param name="searchQuery">The search query.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The search results.</returns>
+ public static Task<ISearchResults<ISearchResult<IArtist>>> FindArtistsWithRetryAsync(this Query query, string searchQuery, ILogger logger, CancellationToken cancellationToken)
+ => RetryOnTransientErrorAsync(
+ token => query.FindArtistsAsync(searchQuery, null, null, false, token),
+ "artist search",
+ logger,
+ cancellationToken);
+
+ /// <summary>
+ /// Searches for releases, retrying when the MusicBrainz server is too busy to answer.
+ /// </summary>
+ /// <param name="query">The MusicBrainz query client.</param>
+ /// <param name="searchQuery">The search query.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The search results.</returns>
+ public static Task<ISearchResults<ISearchResult<IRelease>>> FindReleasesWithRetryAsync(this Query query, string searchQuery, ILogger logger, CancellationToken cancellationToken)
+ => RetryOnTransientErrorAsync(
+ token => query.FindReleasesAsync(searchQuery, null, null, false, token),
+ "release search",
+ logger,
+ cancellationToken);
+
+ /// <summary>
+ /// Runs a request, retrying it when MusicBrainz reports a transient failure. MusicBrainz sheds load with
+ /// HTTP 503 when its servers are busy, which is not specific to this client and succeeds when retried, so
+ /// failing the whole refresh on the first one would leave items without metadata for no good reason.
+ /// </summary>
+ /// <typeparam name="T">The type of the request result.</typeparam>
+ /// <param name="request">The request to run.</param>
+ /// <param name="operation">The operation being performed, used for logging.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The request result.</returns>
+ internal static async Task<T> RetryOnTransientErrorAsync<T>(Func<CancellationToken, Task<T>> request, string operation, ILogger logger, CancellationToken cancellationToken)
+ {
+ for (var attempt = 1; ; attempt++)
+ {
+ try
+ {
+ return await request(cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpError ex) when (attempt <= MaxRetries && IsTransient(ex.Status))
+ {
+ var delay = GetRetryDelay(ex, attempt);
+ logger.LogDebug(
+ ex,
+ "MusicBrainz {Operation} failed with {Status}, retrying in {Delay} (attempt {Attempt} of {Attempts})",
+ operation,
+ ex.Status,
+ delay,
+ attempt,
+ MaxRetries + 1);
+
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Determines whether a response status is worth retrying. These are all cases of the server being unable to
+ /// answer right now rather than of the request itself being wrong.
+ /// </summary>
+ /// <param name="status">The status returned by MusicBrainz.</param>
+ /// <returns>Whether the request should be retried.</returns>
+ private static bool IsTransient(HttpStatusCode status)
+ => status is HttpStatusCode.TooManyRequests
+ or HttpStatusCode.BadGateway
+ or HttpStatusCode.ServiceUnavailable
+ or HttpStatusCode.GatewayTimeout;
+
+ /// <summary>
+ /// Works out how long to wait before retrying. MusicBrainz reports when its current rate limit window ends and
+ /// retrying before then is documented to fail, so that hint wins over the exponential backoff when it is longer.
+ /// </summary>
+ /// <param name="error">The error returned by MusicBrainz.</param>
+ /// <param name="attempt">The number of the attempt that just failed.</param>
+ /// <returns>The time to wait before the next attempt.</returns>
+ internal static TimeSpan GetRetryDelay(HttpError error, int attempt)
+ {
+ var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
+ var hint = GetServerHint(error);
+ if (hint > delay)
+ {
+ delay = hint.Value;
+ }
+
+ if (delay < _minimumRetryDelay)
+ {
+ return _minimumRetryDelay;
+ }
+
+ return delay > _maximumRetryDelay ? _maximumRetryDelay : delay;
+ }
+
+ private static TimeSpan? GetServerHint(HttpError error)
+ {
+ var headers = error.ResponseHeaders;
+ if (headers is null)
+ {
+ return null;
+ }
+
+ var retryAfter = headers.RetryAfter;
+ if (retryAfter?.Delta is { } delta)
+ {
+ return delta;
+ }
+
+ if (retryAfter?.Date is { } date)
+ {
+ return date - DateTimeOffset.UtcNow;
+ }
+
+ var rateLimit = new RateLimitInfo(headers);
+ if (rateLimit.ResetIn is { } resetIn)
+ {
+ return TimeSpan.FromSeconds(resetIn);
+ }
+
+ return rateLimit.ResetAt - rateLimit.LastRequest;
+ }
/// <summary>
/// Runs a lookup, mapping a "not found" response to <see langword="null"/>. Identifiers stored on a library item
@@ -95,13 +238,14 @@ internal static class MusicBrainzQueryExtensions
/// <param name="entityType">The type of entity being looked up, used for logging.</param>
/// <param name="id">The identifier being looked up, used for logging.</param>
/// <param name="logger">The logger.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The entity, or <see langword="null"/> if MusicBrainz does not have it.</returns>
- private static async Task<T?> NotFoundAsNullAsync<T>(Func<Task<T>> lookup, string entityType, Guid id, ILogger logger)
+ private static async Task<T?> NotFoundAsNullAsync<T>(Func<CancellationToken, Task<T>> lookup, string entityType, Guid id, ILogger logger, CancellationToken cancellationToken)
where T : class
{
try
{
- return await lookup().ConfigureAwait(false);
+ return await RetryOnTransientErrorAsync(lookup, entityType + " lookup", logger, cancellationToken).ConfigureAwait(false);
}
catch (HttpError ex) when (ex.Status == HttpStatusCode.NotFound)
{
diff --git a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs
index 7e9befa8c6..0278469807 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs
@@ -1,4 +1,5 @@
using System;
+using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Jellyfin.Extensions.Json;
@@ -6,7 +7,7 @@ using Jellyfin.Extensions.Json;
namespace MediaBrowser.Providers.Plugins.Omdb
{
/// <summary>
- /// Converts a string <c>N/A</c> to <c>string.Empty</c>.
+ /// Converts a string <c>N/A</c> to <c>string.Empty</c> and decodes HTML entities in every other string.
/// </summary>
public class JsonOmdbNotAvailableStringConverter : JsonConverter<string?>
{
@@ -27,7 +28,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb
return null;
}
- return str;
+ // Some OMDb records are HTML encoded, e.g. the cast of tt0093058 lists "Vincent D&apos;Onofrio".
+ // Stored verbatim that name is a second person next to the correctly spelled one.
+ return WebUtility.HtmlDecode(str);
}
return JsonSerializer.Deserialize<string?>(ref reader, options);
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
index 7b245ea5a7..c81ada3e00 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
@@ -13,7 +13,6 @@ using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
-using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -45,9 +44,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
_libraryManager = libraryManager;
_omdbProvider = new OmdbProvider(_httpClientFactory, fileSystem, configurationManager);
- _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options);
- _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter());
- _jsonOptions.Converters.Add(new JsonOmdbNotAvailableInt32Converter());
+ _jsonOptions = OmdbProvider.CreateJsonOptions();
}
public string Name => "The Open Movie Database";
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index d51d913caa..60e4001189 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -45,10 +45,19 @@ namespace MediaBrowser.Providers.Plugins.Omdb
_fileSystem = fileSystem;
_configurationManager = configurationManager;
- _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options);
- // These converters need to take priority
- _jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableStringConverter());
- _jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableInt32Converter());
+ _jsonOptions = CreateJsonOptions();
+ }
+
+ /// <summary>Creates the <see cref="JsonSerializerOptions"/> to read OMDB responses with.</summary>
+ /// <returns>The options.</returns>
+ internal static JsonSerializerOptions CreateJsonOptions()
+ {
+ var options = new JsonSerializerOptions(JsonDefaults.Options);
+ // These have to be inserted, appending leaves the default string converter ahead of them
+ options.Converters.Insert(0, new JsonOmdbNotAvailableStringConverter());
+ options.Converters.Insert(0, new JsonOmdbNotAvailableInt32Converter());
+
+ return options;
}
/// <summary>Fetches data from OMDB service.</summary>
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
index 8172ab14df..83630bb20f 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
@@ -124,6 +124,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// Forces a deep copy of the first TvEpisode, so we don't modify the original because it's cached
episodeResult = new TvEpisode()
{
+ Id = result[0].Id,
Name = result[0].Name,
Overview = result[0].Overview,
AirDate = result[0].AirDate,
@@ -188,6 +189,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
CommunityRating = Convert.ToSingle(episodeResult.VoteAverage)
};
+ if (episodeResult.Id is int episodeTmdbId and > 0)
+ {
+ item.SetProviderId(MetadataProvider.Tmdb, episodeTmdbId.ToString(CultureInfo.InvariantCulture));
+ }
+
var externalIds = episodeResult.ExternalIds;
item.TrySetProviderId(MetadataProvider.Tvdb, externalIds?.TvdbId);
item.TrySetProviderId(MetadataProvider.Imdb, externalIds?.ImdbId);
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs
index b44361e4e7..7646c08329 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs
@@ -118,7 +118,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// create a duplicate.
// When missing episodes are disabled, this pass also prunes virtual episodes that aired more
// than the grace period ago, as well as any specials when specials are not wanted.
- var (existingEpisodes, updatableEpisodes) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays, importSpecials, out var prunedEpisodes);
+ var (existingEpisodes, updatableEpisodes, physicalTmdbIds) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays, importSpecials, out var prunedEpisodes);
var seasonsByNumber = item.GetRecursiveChildren(i => i is Season)
.OfType<Season>()
@@ -197,6 +197,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
continue;
}
+ // The slot is free, but the episode itself may not be: when the files follow a different
+ // order, the same episode sits at another number and creating this one would duplicate it.
+ if (physicalTmdbIds.Contains(tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture)))
+ {
+ continue;
+ }
+
var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false);
var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate);
await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false);
@@ -364,11 +371,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase));
}
- private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable) GetExistingEpisodes(Series series, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials, out bool pruned)
+ private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable, HashSet<string> PhysicalTmdbIds) GetExistingEpisodes(Series series, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials, out bool pruned)
{
var keys = new HashSet<(int Season, int Episode)>();
var updatable = new Dictionary<(int Season, int Episode), Episode>();
var physicalKeys = new HashSet<(int Season, int Episode)>();
+ var physicalTmdbIds = new HashSet<string>(StringComparer.Ordinal);
var ourVirtuals = new List<((int Season, int Episode) Key, Episode Episode)>();
pruned = false;
@@ -403,6 +411,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
continue;
}
+ if (!episode.IsVirtualItem)
+ {
+ // Which episode a file holds is what TMDb's episode id says, not the number the file is
+ // stored under: aired, DVD and absolute orders number the same episodes differently.
+ var physicalTmdbId = episode.GetProviderId(MetadataProvider.Tmdb);
+ if (!string.IsNullOrEmpty(physicalTmdbId))
+ {
+ physicalTmdbIds.Add(physicalTmdbId);
+ }
+ }
+
if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue)
{
var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value);
@@ -432,6 +451,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
DeleteEpisode(episode, "a physical episode now exists for this slot");
pruned = true;
}
+ else if (physicalTmdbIds.Contains(episode.GetProviderId(MetadataProvider.Tmdb)!))
+ {
+ DeleteEpisode(episode, "the same episode is already present under another number");
+ pruned = true;
+ }
else
{
// Virtual episodes this provider created are candidates for metadata sync.
@@ -439,7 +463,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
}
}
- return (keys, updatable);
+ return (keys, updatable, physicalTmdbIds);
}
/// <summary>
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index d75ebae988..8dd1a546fa 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -675,7 +675,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
var image = images[i];
var imageType = type;
- var language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage);
+ var language = TmdbUtils.GetImageLanguage(image.Iso_639_1, image.Iso_3166_1, requestLanguage);
// Return Backdrops with a language specified (it has text) as Thumb.
if (imageType == ImageType.Backdrop && !string.IsNullOrEmpty(language))
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
index f004251594..c8db90799d 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -464,30 +464,41 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
- /// Adjusts the image's language code preferring the 5 letter language code eg. en-US.
+ /// Determines the language code to report for an image.
/// </summary>
- /// <param name="imageLanguage">The image's actual language code.</param>
+ /// <remarks>
+ /// TMDb keeps an image's region in iso_3166_1, which is what separates a pt-PT poster from a pt-BR
+ /// one. The region is added to the returned code only when it differs from the requested one, as in
+ /// a pt-PT image for a pt-BR request. TMDb files nearly every image under a region, so adding it
+ /// unconditionally would return en-US for an image that has to keep matching a plain "en" request.
+ /// </remarks>
+ /// <param name="imageLanguage">The image's ISO 639-1 language code.</param>
+ /// <param name="imageRegion">The image's ISO 3166-1 country code.</param>
/// <param name="requestLanguage">The requested language code.</param>
/// <returns>The language code.</returns>
- public static string AdjustImageLanguage(string? imageLanguage, string requestLanguage)
+ public static string GetImageLanguage(string? imageLanguage, string? imageRegion, string? requestLanguage)
{
- if (string.IsNullOrEmpty(imageLanguage))
+ // TMDb now returns xx for no language instead of an empty string.
+ if (string.IsNullOrEmpty(imageLanguage) || string.Equals(imageLanguage, "xx", StringComparison.OrdinalIgnoreCase))
{
return string.Empty;
}
- if (!string.IsNullOrEmpty(requestLanguage)
- && requestLanguage.Length > 2
- && imageLanguage.Length == 2
- && requestLanguage.StartsWith(imageLanguage, StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrEmpty(requestLanguage))
{
- return requestLanguage;
+ return imageLanguage;
}
- // TMDb now returns xx for no language instead of an empty string.
- return string.Equals(imageLanguage, "xx", StringComparison.OrdinalIgnoreCase)
- ? string.Empty
- : imageLanguage;
+ var requestParts = requestLanguage.Split('-');
+
+ if (requestParts.Length != 2 || !string.Equals(requestParts[0], imageLanguage, StringComparison.OrdinalIgnoreCase))
+ {
+ return imageLanguage;
+ }
+
+ return string.IsNullOrEmpty(imageRegion) || string.Equals(imageRegion, requestParts[1], StringComparison.OrdinalIgnoreCase)
+ ? requestLanguage
+ : imageLanguage + "-" + imageRegion.ToUpperInvariant();
}
/// <summary>
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index b350f482c3..8bec40c9c4 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -207,14 +207,57 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
}
}
+ internal static HashSet<string> GetPresentEpisodeIds(IEnumerable<Episode> episodes)
+ {
+ var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
+ foreach (var episode in episodes)
+ {
+ if (episode.IsVirtualItem || episode.IsMissingEpisode)
+ {
+ continue;
+ }
+
+ foreach (var (provider, id) in episode.ProviderIds)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ continue;
+ }
+
+ var key = IdentityKey(provider, id);
+ counts[key] = counts.GetValueOrDefault(key) + 1;
+ }
+ }
+
+ return [.. counts.Where(e => e.Value == 1).Select(e => e.Key)];
+ }
+
+ internal static bool IsAlreadyPresent(Episode episode, HashSet<string> presentEpisodeIds)
+ {
+ foreach (var (provider, id) in episode.ProviderIds)
+ {
+ if (!string.IsNullOrWhiteSpace(id) && presentEpisodeIds.Contains(IdentityKey(provider, id)))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string IdentityKey(string provider, string id) => provider + "=" + id;
+
private void RemoveObsoleteEpisodes(Series series)
{
- var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true)
+ var episodes = series.GetEpisodes(null, new DtoOptions(), true)
.OfType<Episode>()
- .GroupBy(e => e.ParentIndexNumber)
.ToList();
- foreach (var seasonEpisodes in episodesBySeason)
+ // Matched across the whole series, not per season: the orders disagree about which season an episode
+ // belongs to as readily as they disagree about its number.
+ var presentEpisodeIds = GetPresentEpisodeIds(episodes);
+
+ foreach (var seasonEpisodes in episodes.GroupBy(e => e.ParentIndexNumber))
{
List<Episode> nonPhysicalEpisodes = [];
List<Episode> physicalEpisodes = [];
@@ -233,26 +276,35 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
foreach (var episode in nonPhysicalEpisodes)
{
// Episodes without an episode number are practically orphaned and should be deleted
- // Episodes with a physical equivalent should be deleted (they are no longer missing)
- var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(episode.IndexNumber.Value));
+ if (!episode.IndexNumber.HasValue)
+ {
+ DeleteEpisode(episode, "it has no episode number");
+ continue;
+ }
- if (shouldKeep)
+ // Episodes with a physical equivalent should be deleted (they are no longer missing)
+ if (physicalEpisodes.Any(e => e.ContainsEpisodeNumber(episode.IndexNumber.Value)))
{
+ DeleteEpisode(episode, "a physical episode has that number");
continue;
}
- DeleteEpisode(episode);
+ if (IsAlreadyPresent(episode, presentEpisodeIds))
+ {
+ DeleteEpisode(episode, "the same episode is already present under another number");
+ }
}
}
}
- private void DeleteEpisode(Episode episode)
+ private void DeleteEpisode(Episode episode, string reason)
{
Logger.LogInformation(
- "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
+ "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}",
episode.ParentIndexNumber,
episode.IndexNumber,
- episode.SeriesName);
+ episode.SeriesName,
+ reason);
LibraryManager.DeleteItem(
episode,
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
index 0a72287ba9..6efced6556 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
@@ -45,6 +45,15 @@ public interface IJellyfinDatabaseProvider
Task RunScheduledOptimisation(CancellationToken cancellationToken);
/// <summary>
+ /// If supported this should refresh the query planner statistics, e.g. after a library scan changed the data.
+ /// Unlike <see cref="RunScheduledOptimisation(CancellationToken)"/> it should not reclaim space, so that it stays
+ /// cheap enough to run after every scan.
+ /// </summary>
+ /// <param name="cancellationToken">The token to abort the operation.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task RefreshStatistics(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ /// <summary>
/// If supported this should perform any actions that are required on stopping the jellyfin server. This runs
/// against a deadline imposed by the service manager, so unlike
/// <see cref="RunScheduledOptimisation(CancellationToken)"/> it should only do work whose cost does not grow with
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
index 379da0e9be..8c03347736 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
@@ -33,12 +33,24 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
-- deletes an item: reattach it to the placeholder item instead of letting the
-- FK_UserData_BaseItems_ItemId cascade wipe it. The placeholder can only hold one row
-- per (UserId, CustomDataKey), so resolve collisions before repointing anything.
+ DROP TABLE IF EXISTS "DoomedUserDataKeys";
+ CREATE TEMPORARY TABLE "DoomedUserDataKeys" (
+ "UserId" TEXT NOT NULL,
+ "CustomDataKey" TEXT NOT NULL,
+ PRIMARY KEY ("UserId", "CustomDataKey"));
+
+ -- Collect the colliding keys up front: correlating against "UserData" directly makes
+ -- the delete below re-scan every row of that user once per placeholder row.
+ INSERT OR IGNORE INTO "DoomedUserDataKeys" ("UserId", "CustomDataKey")
+ SELECT Doomed."UserId", Doomed."CustomDataKey"
+ FROM "UserData" AS Doomed
+ INNER JOIN "OrphanedBaseItemIds" AS Orphan ON Orphan."Id" = Doomed."ItemId";
+
DELETE FROM "UserData"
WHERE "ItemId" = '00000000-0000-0000-0000-000000000001'
AND EXISTS (
SELECT 1
- FROM "UserData" AS Doomed
- INNER JOIN "OrphanedBaseItemIds" AS Orphan ON Orphan."Id" = Doomed."ItemId"
+ FROM "DoomedUserDataKeys" AS Doomed
WHERE Doomed."UserId" = "UserData"."UserId"
AND Doomed."CustomDataKey" = "UserData"."CustomDataKey");
@@ -63,6 +75,7 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
DELETE FROM "BaseItems" WHERE "Id" IN (SELECT "Id" FROM "OrphanedBaseItemIds");
+ DROP TABLE "DoomedUserDataKeys";
DROP TABLE "OrphanedBaseItemIds";
""");
@@ -106,41 +119,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
columns: new[] { "BaseItemEntityId", "Name", "OwnerId" },
values: new object[] { null, "This is a placeholder item for UserData that has been detached from its original item", null });
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_BaseItemEntityId",
- table: "BaseItems",
- column: "BaseItemEntityId");
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_ExtraType",
- table: "BaseItems",
- column: "ExtraType");
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_ExtraType_OwnerId",
- table: "BaseItems",
- columns: new[] { "ExtraType", "OwnerId" });
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_OwnerId",
- table: "BaseItems",
- column: "OwnerId");
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated",
- table: "BaseItems",
- columns: new[] { "TopParentId", "IsFolder", "IsVirtualItem", "DateCreated" });
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated",
- table: "BaseItems",
- columns: new[] { "TopParentId", "MediaType", "IsVirtualItem", "DateCreated" });
-
- migrationBuilder.CreateIndex(
- name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated",
- table: "BaseItems",
- columns: new[] { "TopParentId", "Type", "IsVirtualItem", "DateCreated" });
-
+ // No CreateIndex calls here on purpose: AddForeignKey rebuilds BaseItems on SQLite and
+ // recreates every index of the target model afterwards, so building them first only
+ // pays for a full index pass that the rebuild immediately throws away.
migrationBuilder.AddForeignKey(
name: "FK_BaseItems_BaseItems_BaseItemEntityId",
table: "BaseItems",
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
index 3330b64b69..a3fbe42e02 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
@@ -110,6 +110,35 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
/// <inheritdoc/>
+ public async Task RefreshStatistics(CancellationToken cancellationToken)
+ {
+ if (DbContextFactory is null)
+ {
+ return;
+ }
+
+ var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (!await HasLibraryItemsAsync(context, cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ _logger.LogInformation("Analyzing jellyfin.db");
+ await AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ await context.Database.CloseConnectionAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ /// <inheritdoc/>
public void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc);
@@ -161,14 +190,11 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
try
{
long? tempStore;
- long? analysisLimit;
var pragmaCommand = context.Database.GetDbConnection().CreateCommand();
await using (pragmaCommand.ConfigureAwait(false))
{
pragmaCommand.CommandText = "PRAGMA temp_store";
tempStore = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false);
- pragmaCommand.CommandText = "PRAGMA analysis_limit";
- analysisLimit = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false);
}
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
@@ -192,19 +218,15 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
}
- await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false);
- try
+ // Statistics taken while the library is empty make the planner treat every table as one row and
+ // pick full scans once it fills up; no statistics at all plan far better until there is data.
+ if (await HasLibraryItemsAsync(context, cancellationToken).ConfigureAwait(false))
{
- await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
+ await AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
}
- finally
+ else
{
- if (analysisLimit is not null)
- {
- await context.Database.ExecuteSqlRawAsync(
- FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"),
- CancellationToken.None).ConfigureAwait(false);
- }
+ _logger.LogInformation("Not analyzing jellyfin.db, the library holds no items yet");
}
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
@@ -217,6 +239,39 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
}
+ private static Task<bool> HasLibraryItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ // Folders and the seeded placeholder exist before any library has been scanned.
+ return context.BaseItems.AnyAsync(e => !e.IsFolder && e.Type != "PLACEHOLDER", cancellationToken);
+ }
+
+ private static async Task AnalyzeAsync(JellyfinDbContext context, CancellationToken cancellationToken)
+ {
+ long? analysisLimit;
+ var pragmaCommand = context.Database.GetDbConnection().CreateCommand();
+ await using (pragmaCommand.ConfigureAwait(false))
+ {
+ pragmaCommand.CommandText = "PRAGMA analysis_limit";
+ analysisLimit = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false);
+ }
+
+ await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ // The connection goes back to the pool, so hand it over the way it was handed to us.
+ if (analysisLimit is not null)
+ {
+ await context.Database.ExecuteSqlRawAsync(
+ FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"),
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ }
+
private static async Task<long?> ReadPragmaValueAsync(DbCommand command, CancellationToken cancellationToken)
{
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
diff --git a/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs
new file mode 100644
index 0000000000..77321f0b05
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+public class SubtitleControllerTests
+{
+ private const int SubtitleIndex = 2;
+
+ private static readonly Guid _itemId = new("6e1f0a3b7c8d4e5f9a0b1c2d3e4f5a6b");
+
+ [Theory]
+ // Graphical subtitle tracks are negotiated as SubtitleDeliveryMethod.External and must be handed
+ // to the client untouched, because neither format can be parsed or rewritten.
+ [InlineData("pgssub", "pgssub", "/cache/subs/sub.sup")]
+ // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub is exposed as .mks
+ // while the negotiated profile format stays "vobsub".
+ [InlineData("vobsub", "dvdsub", "/cache/subs/sub.mks")]
+ public async Task GetSubtitle_GraphicalStream_ReturnsRawFileWithRangeProcessing(string format, string codec, string path)
+ {
+ var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict);
+ var controller = CreateController(codec, path, encoder, out var fileSystem);
+ fileSystem.Setup(f => f.FileExists(path)).Returns(true);
+
+ var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, format, null, null, null, null, null);
+
+ var fileResult = Assert.IsType<PhysicalFileResult>(result);
+ Assert.Equal(path, fileResult.FileName);
+ Assert.True(fileResult.EnableRangeProcessing);
+ encoder.Verify(
+ e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<long>(), It.IsAny<long>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task GetSubtitle_RemotelyHostedGraphicalStream_FallsBackToEncoder()
+ {
+ var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict);
+ var controller = CreateController("pgssub", "https://example.com/sub.sup", encoder, out var fileSystem);
+ fileSystem.Setup(f => f.FileExists(It.IsAny<string>())).Returns(false);
+ encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "pgssub", 0, 0, false, It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new MemoryStream([1, 2, 3]));
+
+ var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "pgssub", null, null, null, null, null);
+
+ Assert.IsType<FileStreamResult>(result);
+ }
+
+ [Fact]
+ public async Task GetSubtitle_VobSubIdxPair_FallsBackToEncoder()
+ {
+ // A .idx/.sub pair has no raw payload the client could render: the encoder converts it.
+ var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict);
+ var controller = CreateController("dvdsub", "/media/sub.idx", encoder, out _);
+ encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "vobsub", 0, 0, false, It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new MemoryStream([1, 2, 3]));
+
+ var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "vobsub", null, null, null, null, null);
+
+ Assert.IsType<FileStreamResult>(result);
+ }
+
+ [Fact]
+ public async Task GetSubtitle_TextStream_IsEncoded()
+ {
+ var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict);
+ var controller = CreateController("subrip", "/media/sub.srt", encoder, out _);
+ encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "vtt", 0, 0, false, It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new MemoryStream([1, 2, 3]));
+
+ var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "vtt", null, null, null, null, null);
+
+ Assert.IsType<FileStreamResult>(result);
+ }
+
+ private static SubtitleController CreateController(
+ string codec,
+ string subtitlePath,
+ Mock<ISubtitleEncoder> subtitleEncoder,
+ out Mock<IFileSystem> fileSystem)
+ {
+ var item = new Movie { Id = _itemId };
+ var subtitleStream = new MediaStream
+ {
+ Type = MediaStreamType.Subtitle,
+ Index = SubtitleIndex,
+ Codec = codec,
+ Path = subtitlePath,
+ IsExternal = true
+ };
+
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = _itemId.ToString("N"),
+ MediaStreams = new List<MediaStream> { subtitleStream }
+ };
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.GetItemById<BaseItem>(_itemId)).Returns(item);
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(m => m.GetStaticMediaSources(item, false, null)).Returns([mediaSource]);
+
+ subtitleEncoder.Setup(e => e.GetSubtitleFilePath(subtitleStream, mediaSource, It.IsAny<CancellationToken>()))
+ .ReturnsAsync(subtitlePath);
+
+ fileSystem = new Mock<IFileSystem>();
+
+ return new SubtitleController(
+ Mock.Of<IServerConfigurationManager>(),
+ libraryManager.Object,
+ Mock.Of<ISubtitleManager>(),
+ subtitleEncoder.Object,
+ mediaSourceManager.Object,
+ Mock.Of<IProviderManager>(),
+ fileSystem.Object,
+ NullLogger<SubtitleController>.Instance);
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index e072bccb82..c67717e2fa 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -40,15 +40,21 @@ public class BaseItemTests
var previousLibrary = BaseItem.LibraryManager;
var previousRepository = BaseItem.ItemRepository;
var previousLogger = BaseItem.Logger;
+ var previousMediaSourceManager = BaseItem.MediaSourceManager;
var library = new Mock<ILibraryManager>(MockBehavior.Strict);
var repository = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>(MockBehavior.Strict);
var directory = new Mock<IDirectoryService>();
directory.Setup(d => d.IsAccessible(It.IsAny<string>())).Returns(true);
+
+ // IsLibraryFolderAccessible reads FileNameWithoutExtension, which resolves the path protocol
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
try
{
BaseItem.LibraryManager = library.Object;
BaseItem.ItemRepository = repository.Object;
BaseItem.Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<BaseItem>.Instance;
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
var folder = new FailingEnumerationFolder(failAfterFirstChild, accessDenied)
{
Id = Guid.NewGuid(),
@@ -64,6 +70,7 @@ public class BaseItemTests
BaseItem.LibraryManager = previousLibrary;
BaseItem.ItemRepository = previousRepository;
BaseItem.Logger = previousLogger;
+ BaseItem.MediaSourceManager = previousMediaSourceManager;
}
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
index 70da5eafe5..1794469359 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
@@ -9,14 +9,21 @@ using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
-using MediaBrowser.Model.Querying;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests.Entities;
-public class PlaylistTests
+[Collection("LibraryManagerTests")]
+public sealed class PlaylistTests : IDisposable
{
+ private readonly ILibraryManager? _previousLibraryManager = BaseItem.LibraryManager;
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
+
[Fact]
public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden()
{
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
index 2b009b4673..aca55fbcc1 100644
--- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
@@ -261,6 +261,66 @@ public class EncodingHelperTests
Assert.Contains("-ar 48000", args, StringComparison.Ordinal);
}
+ [Fact]
+ public void GetProgressiveAudioFullCommandLine_StereoDownmix_AppliesDownMixAlgorithm()
+ {
+ // Issue #18194: -ac 2 alone drops the LFE channel, the configured downmix filter must be applied.
+ var state = BuildAudioState("aac", 48000);
+ state.AudioStream.Channels = 6;
+ state.AudioStream.ChannelLayout = "5.1";
+ state.OutputAudioChannels = 2;
+ var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750, DownMixAudioBoost = 1 };
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, options, "/tmp/out");
+
+ Assert.Contains(
+ "-af \"" + DownMixAlgorithmsHelper.AlgorithmFilterStrings[(DownMixStereoAlgorithms.Dave750, "5.1")] + "\"",
+ args,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetProgressiveAudioFullCommandLine_NoDownmix_EmitsNoAudioFilter()
+ {
+ var state = BuildAudioState("aac", 48000);
+ state.AudioStream.Channels = 2;
+ state.OutputAudioChannels = 2;
+ var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 };
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, options, "/tmp/out");
+
+ Assert.DoesNotContain("-af", args, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(6)]
+ public void GetProgressiveVideoAudioArguments_NonStereoOutput_KeepsChannelCount(int outputChannels)
+ {
+ // The downmix filter only applies to stereo output, so -ac must not be dropped otherwise.
+ var state = BuildAudioState("aac", 48000);
+ state.AudioStream.Channels = 6;
+ state.AudioStream.ChannelLayout = "5.1";
+ state.OutputAudioChannels = outputChannels;
+ var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 };
+ var args = CreateHelper().GetProgressiveVideoAudioArguments(state, options);
+
+ Assert.Contains("-ac " + outputChannels, args, StringComparison.Ordinal);
+ Assert.DoesNotContain("pan=", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetProgressiveVideoAudioArguments_StereoDownmix_UsesFilterInsteadOfChannelCount()
+ {
+ var state = BuildAudioState("aac", 48000);
+ state.AudioStream.Channels = 6;
+ state.AudioStream.ChannelLayout = "5.1";
+ state.OutputAudioChannels = 2;
+ var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 };
+ var args = CreateHelper().GetProgressiveVideoAudioArguments(state, options);
+
+ Assert.DoesNotContain("-ac ", args, StringComparison.Ordinal);
+ Assert.Contains("pan=stereo", args, StringComparison.Ordinal);
+ }
+
private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate, string? outputContainer = null)
{
var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 };
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs
index e6b0409db7..4e1de22790 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs
@@ -25,5 +25,15 @@ namespace Jellyfin.Naming.Tests.TV
Assert.Equal(name, res.SeriesName);
Assert.True(res.Success);
}
+
+ [Theory]
+ [InlineData("/media/Jujutsu Kaisen (BD_1280x720)")]
+ [InlineData("/media/Show.1920x1080.BluRay")]
+ public void SeriesPathParser_ResolutionPatternIsNotASeries(string path)
+ {
+ var res = SeriesPathParser.Parse(_namingOptions, path);
+
+ Assert.False(res.Success);
+ }
}
}
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
index 023c6cb2fa..0c44595441 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
@@ -25,6 +25,8 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("Marvel's.Agents.of.S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")]
[InlineData("The.Show.S.H.O.W", "The Show S.H.O.W")]
[InlineData("/some/path/Dawson's Creek", "Dawson's Creek")]
+ [InlineData("/media/Bunker.S03.1080p.PULSAR.WEB-DL.DDP5.1.Atmos.H.264-showWEB", "Bunker")]
+ [InlineData("/media/Outer.Colony.S01.1080p.NOVA.WEB-DL.DDP5.1.H.264.HUN.ENG-QUASAR", "Outer Colony")]
public void SeriesResolverResolveTest(string path, string name)
{
var res = SeriesResolver.Resolve(_namingOptions, path);
diff --git a/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs b/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs
new file mode 100644
index 0000000000..6b0d3a2ab2
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Globalization;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Providers.Plugins.MusicBrainz;
+using MetaBrainz.Common;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Music;
+
+public static class MusicBrainzQueryExtensionsTests
+{
+ [Fact]
+ public static async Task RetryOnTransientErrorAsync_ServerBusy_RetriesAndSucceeds()
+ {
+ var attempts = 0;
+
+ var result = await MusicBrainzQueryExtensions.RetryOnTransientErrorAsync(
+ async _ =>
+ {
+ attempts++;
+ if (attempts == 1)
+ {
+ throw await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero);
+ }
+
+ return "found";
+ },
+ "test",
+ NullLogger.Instance,
+ CancellationToken.None);
+
+ Assert.Equal("found", result);
+ Assert.Equal(2, attempts);
+ }
+
+ [Fact]
+ public static async Task RetryOnTransientErrorAsync_ServerStaysBusy_GivesUp()
+ {
+ var attempts = 0;
+
+ var error = await Assert.ThrowsAsync<HttpError>(() => MusicBrainzQueryExtensions.RetryOnTransientErrorAsync<string>(
+ async _ =>
+ {
+ attempts++;
+ throw await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero);
+ },
+ "test",
+ NullLogger.Instance,
+ CancellationToken.None));
+
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, error.Status);
+ Assert.Equal(3, attempts);
+ }
+
+ [Fact]
+ public static async Task RetryOnTransientErrorAsync_NotFound_DoesNotRetry()
+ {
+ var attempts = 0;
+
+ await Assert.ThrowsAsync<HttpError>(() => MusicBrainzQueryExtensions.RetryOnTransientErrorAsync<string>(
+ async _ =>
+ {
+ attempts++;
+ throw await CreateErrorAsync(HttpStatusCode.NotFound, null);
+ },
+ "test",
+ NullLogger.Instance,
+ CancellationToken.None));
+
+ Assert.Equal(1, attempts);
+ }
+
+ [Fact]
+ public static async Task GetRetryDelay_NoHint_BacksOffExponentially()
+ {
+ var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, null);
+
+ Assert.Equal(TimeSpan.FromSeconds(1), MusicBrainzQueryExtensions.GetRetryDelay(error, 1));
+ Assert.Equal(TimeSpan.FromSeconds(2), MusicBrainzQueryExtensions.GetRetryDelay(error, 2));
+ }
+
+ [Fact]
+ public static async Task GetRetryDelay_RetryAfterZero_WaitsMinimum()
+ {
+ var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero);
+
+ Assert.Equal(TimeSpan.FromSeconds(1), MusicBrainzQueryExtensions.GetRetryDelay(error, 1));
+ }
+
+ [Fact]
+ public static async Task GetRetryDelay_RetryAfterLongerThanBackoff_UsesRetryAfter()
+ {
+ var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.FromSeconds(10));
+
+ Assert.Equal(TimeSpan.FromSeconds(10), MusicBrainzQueryExtensions.GetRetryDelay(error, 1));
+ }
+
+ [Fact]
+ public static async Task GetRetryDelay_LongRetryAfter_IsCapped()
+ {
+ var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.FromHours(1));
+
+ Assert.Equal(TimeSpan.FromSeconds(15), MusicBrainzQueryExtensions.GetRetryDelay(error, 1));
+ }
+
+ [Fact]
+ public static async Task GetRetryDelay_RateLimitWindow_WaitsForReset()
+ {
+ var error = await CreateErrorAsync(
+ HttpStatusCode.ServiceUnavailable,
+ null,
+ headers => headers.TryAddWithoutValidation("X-RateLimit-Reset", DateTimeOffset.UtcNow.AddSeconds(8).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)));
+
+ var delay = MusicBrainzQueryExtensions.GetRetryDelay(error, 1);
+
+ Assert.InRange(delay, TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(8));
+ }
+
+ private static async Task<HttpError> CreateErrorAsync(HttpStatusCode status, TimeSpan? retryAfter, Action<HttpResponseHeaders>? configureHeaders = null)
+ {
+ using var response = new HttpResponseMessage(status);
+ if (retryAfter is not null)
+ {
+ response.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter.Value);
+ }
+
+ configureHeaders?.Invoke(response.Headers);
+
+ return await HttpError.FromResponseAsync(response);
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs
index 3062cb7b42..0275d13242 100644
--- a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs
@@ -63,6 +63,41 @@ namespace Jellyfin.Providers.Tests.Omdb
Assert.Equal(expected, result);
}
+ [Theory]
+ [InlineData("\"Vincent D&apos;Onofrio\"", "Vincent D'Onofrio")]
+ [InlineData("\"Alan Moore &#39;Swamp Thing&#39;\"", "Alan Moore 'Swamp Thing'")]
+ [InlineData("\"Tom &amp; Jerry\"", "Tom & Jerry")]
+ [InlineData("\"Mail &lt;a@b.com&gt; for a translation\"", "Mail <a@b.com> for a translation")]
+ [InlineData("\"Elisa Mont\\u00e9s\"", "Elisa Montés")]
+ [InlineData("\"R&D and 100% & more\"", "R&D and 100% & more")]
+ public void Deserialize_Html_Encoded_String_Is_Decoded(string input, string expected)
+ {
+ var result = JsonSerializer.Deserialize<string?>(input, _options);
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void Deserialize_Html_Encoded_Credits_Are_Decoded()
+ {
+ const string Input = "{\"Title\":\"Full Metal Jacket\",\"Actors\":\"Matthew Modine, R. Lee Ermey, Vincent D&apos;Onofrio\",\"Response\":\"True\"}";
+ var rootObject = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, _options);
+ Assert.NotNull(rootObject);
+ Assert.Equal("Matthew Modine, R. Lee Ermey, Vincent D'Onofrio", rootObject!.Actors);
+ }
+
+ [Fact]
+ public void ProviderJsonOptions_Take_Priority_Over_The_Defaults()
+ {
+ // The default string converter handles string as well, so appending the OMDb ones would never run them
+ var options = OmdbProvider.CreateJsonOptions();
+
+ const string Input = "{\"Title\":\"Full Metal Jacket\",\"Actors\":\"Vincent D&apos;Onofrio\",\"Awards\":\"N/A\",\"Response\":\"True\"}";
+ var rootObject = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, options);
+ Assert.NotNull(rootObject);
+ Assert.Equal("Vincent D'Onofrio", rootObject!.Actors);
+ Assert.Null(rootObject.Awards);
+ }
+
[Fact]
public void Roundtrip_Valid_Success()
{
diff --git a/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs
new file mode 100644
index 0000000000..3f6a7fb0b0
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs
@@ -0,0 +1,107 @@
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.TV;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.TV;
+
+/// <summary>
+/// Covers reconciling missing episodes against the episodes the user has, which cannot be done on episode
+/// numbers alone: aired, DVD and absolute orders number the same episodes differently, so the tail of the
+/// longer order is the episodes the user owns rather than the ones they lack.
+/// </summary>
+public class SeriesMetadataServiceTests
+{
+ public SeriesMetadataServiceTests()
+ {
+ // An episode with a path asks the file system what kind of location it is. Only set when nothing else
+ // in the assembly has, since it is process-wide.
+ BaseItem.FileSystem ??= Mock.Of<IFileSystem>();
+ }
+
+ [Fact]
+ public void IsAlreadyPresent_VirtualEpisodeCarriesTheIdOfAPresentOne_ReturnsTrue()
+ {
+ // The Six Million Dollar Man S3: 21 files numbered in DVD order, 23 episodes in aired order, so
+ // aired 22 and 23 are the files stored as 20 and 21.
+ var present = SeriesMetadataService.GetPresentEpisodeIds(
+ [
+ Physical(20, (MetadataProvider.Tvdb, "236715")),
+ Physical(21, (MetadataProvider.Tvdb, "236716"))
+ ]);
+
+ Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(22, (MetadataProvider.Tvdb, "236715")), present));
+ Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(23, (MetadataProvider.Tvdb, "236716")), present));
+ }
+
+ [Fact]
+ public void IsAlreadyPresent_GenuinelyMissingEpisode_ReturnsFalse()
+ {
+ // The aired-only two-parter the user really does not have keeps its missing episodes.
+ var present = SeriesMetadataService.GetPresentEpisodeIds(
+ [
+ Physical(20, (MetadataProvider.Tvdb, "236715")),
+ Physical(21, (MetadataProvider.Tvdb, "236716"))
+ ]);
+
+ Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(15, (MetadataProvider.Tvdb, "4273533")), present));
+ }
+
+ [Fact]
+ public void GetPresentEpisodeIds_IdSharedBySeveralEpisodes_IsNotAnIdentity()
+ {
+ // A series' own IMDb id lands on every episode, and TMDb reports an unknown TVRage id as 0. Taking
+ // either for an episode identity would let one file delete every missing episode of the series.
+ var present = SeriesMetadataService.GetPresentEpisodeIds(
+ [
+ Physical(1, (MetadataProvider.Imdb, "tt0836592"), (MetadataProvider.TvRage, "0"), (MetadataProvider.Tvdb, "1001")),
+ Physical(2, (MetadataProvider.Imdb, "tt0836592"), (MetadataProvider.TvRage, "0"), (MetadataProvider.Tvdb, "1002"))
+ ]);
+
+ Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(3, (MetadataProvider.Imdb, "tt0836592")), present));
+ Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(4, (MetadataProvider.TvRage, "0")), present));
+ Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(5, (MetadataProvider.Tvdb, "1001")), present));
+ }
+
+ [Fact]
+ public void GetPresentEpisodeIds_VirtualEpisode_DoesNotCountAsPresent()
+ {
+ // Two virtual episodes for the same episode must not cancel each other out into "present".
+ var present = SeriesMetadataService.GetPresentEpisodeIds(
+ [
+ Virtual(22, (MetadataProvider.Tvdb, "236715"))
+ ]);
+
+ Assert.Empty(present);
+ }
+
+ [Fact]
+ public void GetPresentEpisodeIds_BlankStoredId_IsIgnored()
+ {
+ // SetProviderId refuses these, but a row written by an older version can still hold one.
+ var episode = Physical(1);
+ episode.ProviderIds["Tvdb"] = " ";
+
+ Assert.Empty(SeriesMetadataService.GetPresentEpisodeIds([episode]));
+ }
+
+ private static Episode Physical(int indexNumber, params (MetadataProvider Provider, string Id)[] providerIds)
+ => WithIds(new Episode { IndexNumber = indexNumber, Path = $"/media/show/Season 03/E{indexNumber}.mkv" }, providerIds);
+
+ private static Episode Virtual(int indexNumber, params (MetadataProvider Provider, string Id)[] providerIds)
+ => WithIds(new Episode { IndexNumber = indexNumber, IsVirtualItem = true }, providerIds);
+
+ private static Episode WithIds(Episode episode, IEnumerable<(MetadataProvider Provider, string Id)> providerIds)
+ {
+ foreach (var (provider, id) in providerIds)
+ {
+ episode.SetProviderId(provider, id);
+ }
+
+ return episode;
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
index 03bad3555e..8342621d97 100644
--- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
@@ -30,14 +30,25 @@ namespace Jellyfin.Providers.Tests.Tmdb
}
[Theory]
- [InlineData("en", "en-US", "en-US")]
- [InlineData("fr-CA", "fr-BE", "fr-CA")]
- [InlineData("fr-CA", "fr", "fr-CA")]
- [InlineData("de", "en-US", "de")]
- [InlineData("", "en-US", "")]
- public static void AdjustImageLanguage_Valid_Success(string imageLanguage, string requestLanguage, string? expected)
+ // A regional request is only answered with a region when the image contradicts it.
+ [InlineData("pt", "BR", "pt-BR", "pt-BR")]
+ [InlineData("pt", "PT", "pt-BR", "pt-PT")]
+ [InlineData("en", "GB", "en-US", "en-GB")]
+ [InlineData("en", "US", "en-US", "en-US")]
+ [InlineData("en", null, "en-US", "en-US")]
+ // TMDb files nearly every image under a region, which must not stop a plain request matching.
+ [InlineData("en", "US", "en", "en")]
+ [InlineData("pt", "PT", "pt", "pt")]
+ // An image in another language keeps its bare code, so the English fallback still resolves.
+ [InlineData("en", "US", "pt-BR", "en")]
+ [InlineData("de", "DE", "en-US", "de")]
+ // TMDb returns xx rather than an empty string for an image with no text.
+ [InlineData("xx", "US", "en-US", "")]
+ [InlineData("", "US", "en-US", "")]
+ [InlineData(null, null, "en-US", "")]
+ public static void GetImageLanguage_Valid_Success(string? imageLanguage, string? imageRegion, string? requestLanguage, string expected)
{
- Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage));
+ Assert.Equal(expected, TmdbUtils.GetImageLanguage(imageLanguage, imageRegion, requestLanguage));
}
[Theory]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs
new file mode 100644
index 0000000000..a60622902b
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Data;
+
+public class ItemTypeLookupTests
+{
+ public static TheoryData<BaseItemKind> MappedKinds()
+ => new(new ItemTypeLookup().BaseItemKindNames.Keys);
+
+ [Theory]
+ [MemberData(nameof(MappedKinds))]
+ public void BaseItemKindNames_Kind_NamesAnItemType(BaseItemKind kind)
+ {
+ var name = new ItemTypeLookup().BaseItemKindNames[kind];
+
+ var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType(name)).FirstOrDefault(t => t is not null);
+
+ Assert.NotNull(type);
+ Assert.True(typeof(BaseItem).IsAssignableFrom(type), $"{name} is not an item type.");
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs
new file mode 100644
index 0000000000..153e1dc154
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Tests.Item;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Data;
+
+/// <summary>
+/// Statistics taken on a freshly created database describe every table as a single row, and SQLite then plans
+/// the user data and series queries of a filled library as full scans (#17886).
+/// </summary>
+public sealed class SqliteDatabaseStatisticsTests : SqliteDbTestFixture
+{
+ private readonly SqliteDatabaseProvider _provider;
+
+ public SqliteDatabaseStatisticsTests()
+ {
+ _provider = new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance)
+ {
+ DbContextFactory = CreateDbContextFactory()
+ };
+ }
+
+ [Fact]
+ public async Task RunScheduledOptimisation_EmptyLibrary_RecordsNoStatistics()
+ {
+ SeedFolders(3);
+
+ await _provider.RunScheduledOptimisation(CancellationToken.None);
+
+ Assert.Null(ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RunScheduledOptimisation_LibraryWithItems_RecordsStatistics()
+ {
+ SeedFolders(1);
+ SeedEpisodes(4);
+
+ await _provider.RunScheduledOptimisation(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_NoStatistics_Analyzes()
+ {
+ SeedEpisodes(5);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_LibraryChanged_Reanalyzes()
+ {
+ SeedEpisodes(10);
+ Analyze();
+ SeedEpisodes(5);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_EmptyLibrary_RecordsNoStatistics()
+ {
+ SeedFolders(2);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Null(ReadAnalyzedItemCount());
+ }
+
+ private void SeedFolders(int count)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.Folder",
+ IsFolder = true
+ }));
+ context.SaveChanges();
+ }
+
+ private void SeedEpisodes(int count)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.TV.Episode",
+ IsFolder = false
+ }));
+ context.SaveChanges();
+ }
+
+ private void Analyze()
+ {
+ using var context = CreateDbContext();
+ context.Database.ExecuteSqlRaw("ANALYZE");
+ }
+
+ private long CountItems()
+ {
+ using var context = CreateDbContext();
+ return context.BaseItems.LongCount();
+ }
+
+ private long? ReadAnalyzedItemCount()
+ {
+ using var context = CreateDbContext();
+ var hasStatistics = context.Database
+ .SqlQueryRaw<long>("SELECT count(*) AS \"Value\" FROM sqlite_schema WHERE type = 'table' AND name = 'sqlite_stat1'")
+ .Single();
+ if (hasStatistics == 0)
+ {
+ return null;
+ }
+
+ return context.Database
+ .SqlQueryRaw<long?>("SELECT max(CAST(stat AS INTEGER)) AS \"Value\" FROM sqlite_stat1 WHERE tbl = 'BaseItems'")
+ .Single();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs
new file mode 100644
index 0000000000..b88296a06a
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Images;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Querying;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Images;
+
+/// <summary>
+/// A music library is collaged from its artists' backdrops. Artists are by-name items with no
+/// library of their own, so they have to be asked for through the by-name listing, which reaches
+/// them through the tracks that credit them; an item query for them ignores the library scope and
+/// hands back the artists of every music library.
+/// </summary>
+public sealed class CollectionFolderImageProviderTests
+{
+ [Fact]
+ public void GetItemsWithImages_MusicLibrary_AsksForTheArtistsOfThatLibraryOnly()
+ {
+ var view = new CollectionFolder { Id = Guid.NewGuid(), CollectionType = CollectionType.music };
+ var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "Artist" };
+
+ InternalItemsQuery? query = null;
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager
+ .Setup(l => l.GetAllArtists(It.IsAny<InternalItemsQuery>()))
+ .Callback<InternalItemsQuery>(q => query = q)
+ .Returns(new QueryResult<(BaseItem Item, ItemCounts ItemCounts)>([(artist, new ItemCounts())]));
+
+ var items = CreateProvider(libraryManager.Object).GetItems(view);
+
+ Assert.Equal([artist], items);
+ Assert.NotNull(query);
+ Assert.Equal([view.Id], query.AncestorIds);
+ Assert.Equal([ImageType.Primary], query.ImageTypes);
+ Assert.Equal(8, query.Limit);
+ }
+
+ private static TestableCollectionFolderImageProvider CreateProvider(ILibraryManager libraryManager)
+ {
+ return new TestableCollectionFolderImageProvider(
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IProviderManager>(),
+ Mock.Of<IApplicationPaths>(),
+ Mock.Of<IImageProcessor>(),
+ libraryManager);
+ }
+
+ private sealed class TestableCollectionFolderImageProvider : CollectionFolderImageProvider
+ {
+ public TestableCollectionFolderImageProvider(
+ IFileSystem fileSystem,
+ IProviderManager providerManager,
+ IApplicationPaths applicationPaths,
+ IImageProcessor imageProcessor,
+ ILibraryManager libraryManager)
+ : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager)
+ {
+ }
+
+ public IReadOnlyList<BaseItem> GetItems(BaseItem item) => GetItemsWithImages(item);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs
new file mode 100644
index 0000000000..0c1d453293
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Moq;
+using Xunit;
+using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ArtistNameLookupTests : SqliteDbTestFixture
+{
+ private readonly CommandRecorder _recorder;
+
+ public ArtistNameLookupTests()
+ : this(new CommandRecorder())
+ {
+ }
+
+ private ArtistNameLookupTests(CommandRecorder recorder)
+ : base(recorder)
+ {
+ _recorder = recorder;
+ }
+
+ [Theory]
+ [InlineData("Björk", "bjork")]
+ [InlineData("AC/DC", "ac dc")]
+ [InlineData("An Artist", " AN ARTIST ")]
+ [InlineData("Artist", "ARTIST")]
+ public void GetArtist_UsesSameNormalizedNameAsFindArtists(string storedName, string requestedName)
+ {
+ var lookup = new ItemTypeLookup();
+ var artistId = Guid.NewGuid();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ new BaseItemEntity
+ {
+ Id = artistId,
+ Name = storedName,
+ CleanName = storedName.GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ },
+ new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = storedName,
+ CleanName = storedName.GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.Person]
+ });
+ context.SaveChanges();
+ }
+
+ var manager = CreateLibraryManager(lookup);
+ _recorder.Commands.Clear();
+
+ Assert.Equal(artistId, manager.GetArtist(requestedName).Id);
+ var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("\"CleanName\" =", StringComparison.Ordinal));
+ Assert.Contains(Explain(query), line => line.Contains("IX_BaseItems_Type_CleanName (Type=? AND CleanName=?)", StringComparison.Ordinal));
+ Assert.Equal(artistId, Assert.Single(manager.GetArtists([requestedName])[requestedName]).Id);
+ }
+
+ [Fact]
+ public void GetArtist_PrefersFilesystemArtistWhenNormalizedNamesMatch()
+ {
+ var lookup = new ItemTypeLookup();
+ var parentId = Guid.NewGuid();
+ var artistId = Guid.NewGuid();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ new BaseItemEntity
+ {
+ Id = parentId,
+ Name = "Music",
+ Type = lookup.BaseItemKindNames[BaseItemKind.Folder]
+ },
+ new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = "Bjork",
+ CleanName = "Bjork".GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ },
+ new BaseItemEntity
+ {
+ Id = artistId,
+ ParentId = parentId,
+ Name = "Björk",
+ CleanName = "Björk".GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ });
+ context.SaveChanges();
+ }
+
+ Assert.Equal(artistId, CreateLibraryManager(lookup).GetArtist("Bjork").Id);
+ }
+
+ private ServerLibraryManager CreateLibraryManager(ItemTypeLookup lookup)
+ {
+ var repository = CreateBaseItemRepository(lookup);
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ fixture.Inject<IItemRepository>(repository);
+ fixture.Inject<ILinkedChildrenService>(new LinkedChildrenService(CreateDbContextFactory(), lookup, repository));
+ return fixture.Create<ServerLibraryManager>();
+ }
+
+ private string[] Explain(RecordedCommand query)
+ {
+ using var context = CreateDbContext();
+ using var command = context.Database.GetDbConnection().CreateCommand();
+#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters.
+ command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql;
+#pragma warning restore CA2100
+ foreach (var value in query.Parameters)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = value.Name;
+ parameter.Value = value.Value;
+ command.Parameters.Add(parameter);
+ }
+
+ using var reader = command.ExecuteReader();
+ var plan = new List<string>();
+ while (reader.Read())
+ {
+ plan.Add(reader.GetString(3));
+ }
+
+ return plan.ToArray();
+ }
+
+ private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters);
+
+ private sealed class CommandRecorder : DbCommandInterceptor
+ {
+ public List<RecordedCommand> Commands { get; } = [];
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ private void Record(DbCommand command) => Commands.Add(new RecordedCommand(
+ command.CommandText,
+ command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray()));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs
new file mode 100644
index 0000000000..3c8bd9fd50
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// A user restricted to a set of allowed tags still has to see the by-name items - genres, studios,
+/// artists - of the media those tags let through. The by-name item carries none of the media's tags,
+/// so the allowed-tags filter would otherwise hide every one of them.
+/// </summary>
+public sealed class BaseItemRepositoryAllowedTagsByNameTests : SqliteDbTestFixture
+{
+ private const string AllowedTag = "kids";
+
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryAllowedTagsByNameTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+ Seed();
+ }
+
+ [Fact]
+ public void GetGenres_WithAllowedTags_ReturnsGenresOfAllowedItems()
+ {
+ var result = _repository.GetGenres(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Comedy"], Names(result));
+ }
+
+ [Fact]
+ public void GetStudios_WithAllowedTags_ReturnsStudiosOfAllowedItems()
+ {
+ var result = _repository.GetStudios(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Pixar"], Names(result));
+ }
+
+ [Fact]
+ public void GetAllArtists_WithAllowedTags_ReturnsArtistsOfAllowedItems()
+ {
+ var result = _repository.GetAllArtists(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Raffi"], Names(result));
+ }
+
+ [Fact]
+ public void GetGenres_WithoutAllowedTags_ReturnsEveryGenre()
+ {
+ var result = _repository.GetGenres(CreateQuery());
+
+ Assert.Equal(["Comedy", "Horror", "Jazz"], Names(result));
+ }
+
+ private static string[] Names(MediaBrowser.Model.Querying.QueryResult<(BaseItem Item, MediaBrowser.Model.Dto.ItemCounts? ItemCounts)> result)
+ => result.Items.Select(i => i.Item.Name!).Order(StringComparer.Ordinal).ToArray();
+
+ private static InternalItemsQuery CreateQuery(params string[] allowedTags)
+ {
+ var user = new User("restricted", "auth", "reset");
+ if (allowedTags.Length > 0)
+ {
+ user.SetPreference(PreferenceKind.AllowedTags, allowedTags);
+ }
+
+ return new InternalItemsQuery(user);
+ }
+
+ private void Seed()
+ {
+ using var context = CreateDbContext();
+
+ // Tagged media, plus the by-name items describing it.
+ var movie = CreateItem(BaseItemKind.Movie, "Allowed Movie", mediaType: "Video");
+ var song = CreateItem(BaseItemKind.Audio, "Allowed Song", mediaType: "Audio");
+ var genre = CreateItem(BaseItemKind.Genre, "Comedy");
+ var studio = CreateItem(BaseItemKind.Studio, "Pixar");
+ var artist = CreateItem(BaseItemKind.MusicArtist, "Raffi");
+
+ // Media the allow list keeps out, plus the by-name items only it is described by.
+ var blockedMovie = CreateItem(BaseItemKind.Movie, "Untagged Movie", mediaType: "Video");
+ var blockedGenre = CreateItem(BaseItemKind.Genre, "Horror");
+
+ // A genre written on nothing but a by-name item: that item is no more visible than the
+ // media behind it, so the genre must not leak into a restricted user's list.
+ var artistOnlyGenre = CreateItem(BaseItemKind.Genre, "Jazz");
+ var blockedArtist = CreateItem(BaseItemKind.MusicArtist, "Mingus");
+
+ var tag = CreateItemValue(ItemValueType.Tags, AllowedTag);
+ var comedy = CreateItemValue(ItemValueType.Genre, "Comedy");
+ var horror = CreateItemValue(ItemValueType.Genre, "Horror");
+ var jazz = CreateItemValue(ItemValueType.Genre, "Jazz");
+ var pixar = CreateItemValue(ItemValueType.Studios, "Pixar");
+ var raffi = CreateItemValue(ItemValueType.Artist, "Raffi");
+
+ context.BaseItems.AddRange(movie, song, genre, studio, artist, blockedMovie, blockedGenre, artistOnlyGenre, blockedArtist);
+ context.ItemValues.AddRange(tag, comedy, horror, jazz, pixar, raffi);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, tag),
+ CreateMap(movie, comedy),
+ CreateMap(movie, pixar),
+ CreateMap(song, tag),
+ CreateMap(song, raffi),
+ CreateMap(blockedMovie, horror),
+ CreateMap(blockedArtist, jazz));
+ context.SaveChanges();
+ }
+
+ private BaseItemEntity CreateItem(BaseItemKind kind, string name, string? mediaType = null)
+ {
+ var id = Guid.NewGuid();
+
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _itemTypeLookup.BaseItemKindNames[kind],
+ Name = name,
+ CleanName = name.ToLowerInvariant(),
+ PresentationUniqueKey = id.ToString("N"),
+ MediaType = mediaType,
+ IsMovie = kind == BaseItemKind.Movie,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private static ItemValue CreateItemValue(ItemValueType type, string value)
+ {
+ return new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = type,
+ Value = value,
+ CleanValue = value.ToLowerInvariant()
+ };
+ }
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ {
+ return new ItemValueMap
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs
new file mode 100644
index 0000000000..1f8d9820d6
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Artists are by-name items: they live outside any library and carry no TopParentId, so a plain
+/// item query for them is exempt from the library filter and spans every music library. Only the
+/// by-name listings, which reach the artist through the tracks that credit it, can be scoped to
+/// one library.
+/// </summary>
+public sealed class BaseItemRepositoryArtistLibraryScopeTests : SqliteDbTestFixture
+{
+ private static readonly Guid _firstLibrary = Guid.Parse("11111111-0000-0000-0000-000000000001");
+ private static readonly Guid _secondLibrary = Guid.Parse("22222222-0000-0000-0000-000000000001");
+
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryArtistLibraryScopeTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+
+ Seed("First Artist", "first artist", _firstLibrary, hasImage: true);
+ Seed("Second Artist", "second artist", _secondLibrary, hasImage: true);
+ }
+
+ [Fact]
+ public void GetItemList_MusicArtistsScopedToOneLibrary_ReturnsEveryLibrarysArtists()
+ {
+ // The shape the library cover image used to be built from. By-name types are exempt from
+ // the TopParentId filter, so the scope is silently dropped.
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ IncludeItemTypes = [BaseItemKind.MusicArtist],
+ TopParentIds = [_firstLibrary]
+ });
+
+ Assert.Equal(["First Artist", "Second Artist"], result.Select(i => i.Name).OrderBy(n => n));
+ }
+
+ [Fact]
+ public void GetAllArtists_ScopedToOneLibrary_ReturnsOnlyThatLibrarysArtists()
+ {
+ var result = _repository.GetAllArtists(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ TopParentIds = [_firstLibrary]
+ });
+
+ var (artist, _) = Assert.Single(result.Items);
+ Assert.Equal("First Artist", artist.Name);
+ }
+
+ [Fact]
+ public void GetAllArtists_ImageTypes_DropsArtistsWithoutThatImage()
+ {
+ // The collage has nothing to draw with an artist that has no image, so the listing has to
+ // honour the image filter the caller asked for.
+ Seed("Third Artist", "third artist", _firstLibrary, hasImage: false);
+
+ var result = _repository.GetAllArtists(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ ImageTypes = [ImageType.Primary],
+ TopParentIds = [_firstLibrary]
+ });
+
+ var (artist, _) = Assert.Single(result.Items);
+ Assert.Equal("First Artist", artist.Name);
+ }
+
+ /// <summary>
+ /// Seeds one by-name artist row and a track in the given library crediting it.
+ /// </summary>
+ /// <param name="name">The artist name.</param>
+ /// <param name="cleanName">The cleaned artist name, which is what links the two rows.</param>
+ /// <param name="topParentId">The library the track belongs to.</param>
+ /// <param name="hasImage">Whether the artist row carries a primary image.</param>
+ private void Seed(string name, string cleanName, Guid topParentId, bool hasImage)
+ {
+ using var ctx = CreateDbContext();
+
+ var artistId = Guid.NewGuid();
+ var artist = new BaseItemEntity
+ {
+ Id = artistId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist],
+ Name = name,
+ CleanName = cleanName,
+ PresentationUniqueKey = artistId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ };
+
+ if (hasImage)
+ {
+ artist.Images =
+ [
+ new BaseItemImageInfo
+ {
+ Id = Guid.NewGuid(),
+ ItemId = artistId,
+ Item = artist,
+ ImageType = ImageInfoImageType.Primary,
+ Path = $"/metadata/artists/{cleanName}/folder.jpg"
+ }
+ ];
+ }
+
+ ctx.BaseItems.Add(artist);
+
+ var trackId = Guid.NewGuid();
+ var track = new BaseItemEntity
+ {
+ Id = trackId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio],
+ Name = $"{name} - Track",
+ CleanName = $"{cleanName} - track",
+ PresentationUniqueKey = trackId.ToString("N"),
+ MediaType = "Audio",
+ TopParentId = topParentId,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ ctx.BaseItems.Add(track);
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.AlbumArtist,
+ Value = name,
+ CleanValue = cleanName
+ };
+
+ ctx.ItemValues.Add(itemValue);
+ ctx.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = trackId,
+ ItemValueId = itemValue.ItemValueId,
+ Item = track,
+ ItemValue = itemValue
+ });
+
+ ctx.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
index a28c1d6dfb..f717d9b665 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
@@ -29,6 +29,7 @@ public class FindExtrasTests
{
private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager;
private readonly Mock<IFileSystem> _fileSystemMock;
+ private readonly Mock<IItemRepository> _itemRepositoryMock;
public FindExtrasTests()
{
@@ -36,8 +37,8 @@ public class FindExtrasTests
fixture.Register(() => new NamingOptions());
var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>();
configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
- var itemRepository = fixture.Freeze<Mock<IItemRepository>>();
- itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
+ _itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>();
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
_fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
_fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
@@ -482,6 +483,69 @@ public class FindExtrasTests
}
[Fact]
+ public void FindExtras_ExtraNamedByLocalMetadata_KeepsItsNameOnRescan()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var directoryService = new DirectoryService(_fileSystemMock.Object);
+ var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Trailer", extra.Name);
+
+ // A local metadata file gives the extra a title of its own, which the refresh persists
+ extra.Name = "Cannes Teaser";
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra);
+
+ var rescanned = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Cannes Teaser", rescanned.Name);
+ }
+
+ [Fact]
+ public void FindExtras_ExtraKeptItsGeneratedName_IsRenumberedOnRescan()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer2.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var directoryService = new DirectoryService(_fileSystemMock.Object);
+ var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Trailer", extra.Name);
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra);
+
+ // A trailer sorting before the known one takes the first number, so the known one moves on
+ files.Add(new FileSystemMetadata
+ {
+ FullName = "/movies/Up (2009)/Up (2009)-trailer1.mkv",
+ IsDirectory = false
+ });
+
+ var rescanned = _libraryManager.FindExtras(owner, files, directoryService)
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ Assert.Equal("Trailer", rescanned["/movies/Up (2009)/Up (2009)-trailer1.mkv"]);
+ Assert.Equal("Trailer 2", rescanned["/movies/Up (2009)/Up (2009)-trailer2.mkv"]);
+ }
+
+ [Fact]
public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle()
{
var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
index e1346a8436..3aac3cdc43 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using Moq;
@@ -89,4 +90,92 @@ public class MovieResolverTests
Assert.All(result.Items, item => Assert.IsType<Movie>(item));
Assert.Single(((Video)result.Items[0]).LocalAlternateVersions);
}
+
+ [Fact]
+ public void AllExtrasTypesFolderNames_ContainsSampleSingularAndPlural()
+ {
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("sample"));
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("Sample"));
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("samples"));
+ }
+
+ [Theory]
+ [InlineData("Sample")]
+ [InlineData("sample")]
+ [InlineData("SAMPLE")]
+ [InlineData("samples")]
+ public void ResolvePath_MovieFolderWithSampleSubfolder_ResolvesToMovie(string sampleDirName)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+ libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false);
+
+ var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
+ var args = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ libraryManager.Object)
+ {
+ Parent = new Folder(),
+ CollectionType = CollectionType.movies,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)",
+ IsDirectory = true
+ },
+ FileSystemChildren = new[]
+ {
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv",
+ Name = "Outer Colony (2026).mkv"
+ },
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/" + sampleDirName,
+ Name = sampleDirName,
+ IsDirectory = true
+ }
+ }
+ };
+
+ Assert.IsType<Movie>(resolver.ResolvePath(args));
+ }
+
+ [Fact]
+ public void ResolvePath_MovieFolderWithRealSubfolder_DoesNotResolveToSingleMovie()
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+ libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false);
+
+ var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
+ var args = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ libraryManager.Object)
+ {
+ Parent = new Folder(),
+ CollectionType = CollectionType.movies,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)",
+ IsDirectory = true
+ },
+ FileSystemChildren = new[]
+ {
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv",
+ Name = "Outer Colony (2026).mkv"
+ },
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Feature",
+ Name = "Feature",
+ IsDirectory = true
+ }
+ }
+ };
+
+ Assert.Null(resolver.ResolvePath(args));
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 93014e7244..4e57f32b57 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -6,6 +6,7 @@ using BitFaster.Caching;
using Emby.Server.Implementations.Localization;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -180,7 +181,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
await localizationManager.LoadAll();
var ratings = localizationManager.GetParentalRatings().ToList();
- Assert.Equal(24, ratings.Count);
+ Assert.Equal(34, ratings.Count);
var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal));
Assert.NotNull(fsk);
@@ -209,6 +210,37 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
[InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive
[InlineData("VM 18", "IT", 18, null)]
[InlineData("Vietato ai minori di 18 anni", "IT", 18, null)]
+ [InlineData("ATP", "AR", 0, null)]
+ [InlineData("SAM 13", "AR", 13, null)]
+ [InlineData("SAM 16", "AR", 16, null)]
+ [InlineData("SAM 18", "AR", 18, null)]
+ [InlineData("SAM13", "AR", 13, null)] // Written without a space
+ [InlineData("AR-SAM 16", "AR", 16, null)] // Country prefix stripped against the configured country
+ [InlineData("AR-SAM 13", "US", 13, null)] // Country prefix resolved via the separator fallback
+ [InlineData("AR-SAM 18", "US", 18, null)]
+ [InlineData("AR-SAM13", "US", 13, null)]
+ [InlineData("SAM 18 C", "AR", 1001, null)] // Condicionada, same as "C"
+ [InlineData("Interdit aux moins de 12 ans", "FR", 12, null)]
+ [InlineData("Interdit aux moins de 18 ans", "FR", 18, null)]
+ [InlineData("X 18+", "AU", 1000, 0)] // Official spelling of the Australian X rating
+ [InlineData("X18+", "AU", 1000, 0)]
+ [InlineData("FSK18", "DE", 18, null)] // Written without a space
+ [InlineData("ab 18", "DE", 18, null)] // Written as the minimum age
+ [InlineData("DE:ab 6", "DE", 6, null)]
+ [InlineData("–12", "FR", 12, null)] // The CNC writes its minimum ages with an en dash
+ [InlineData("–16", "FR", 16, null)]
+ [InlineData("–18", "FR", 18, null)]
+ [InlineData("SU", "ID", 0, null)] // Indonesian broadcast classifications (KPI)
+ [InlineData("P", "ID", 2, null)]
+ [InlineData("A", "ID", 7, null)]
+ [InlineData("R", "ID", 13, null)]
+ [InlineData("D", "ID", 18, null)]
+ [InlineData("D18+", "ID", 18, null)] // Written with the minimum age, as broadcast since 2016
+ [InlineData("R-BO", "ID", 13, null)] // Parental guidance does not change the age group
+ [InlineData("Dewasa", "ID", 18, null)]
+ [InlineData("ID-D", "ID", 18, null)] // TMDB style country prefix
+ [InlineData("ID-D", "US", 18, null)] // Country prefix resolved via the separator fallback
+ [InlineData("P", "US", 1000, null)] // Ambiguous outside Indonesia, preferred as the Portuguese "Pornográfico"
public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration()
@@ -253,6 +285,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
[InlineData("12", 12, null)]
[InlineData("42", 42, null)]
[InlineData("9999", 9999, null)]
+ // The French CNC writes minimum ages as "-12" ("not for under 12s"). Parsing that as -12 would
+ // put the item below every MaxParentalRatingScore and bypass parental control entirely.
+ [InlineData("-10", 10, null)]
+ [InlineData("-12", 12, null)]
+ [InlineData("-16", 16, null)]
+ [InlineData("-18", 18, null)]
public async Task GetRatingLevel_GivenValidAge_Success(string value, int? expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" });
@@ -323,6 +361,62 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Theory]
+ // Providers list every spelling of a rating in a single field. Splitting such a list by its country
+ // prefix pairs the first entry's country with the last entry's rating, so it has to be split by '/' first.
+ [InlineData("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18", "de", 18, null)]
+ [InlineData("SE:15 / SE:15+ / SE:Från 15 år", "de", 15, null)]
+ [InlineData("FR:16 / US:12", "de", 16, null)] // The first entry that resolves wins
+ public async Task GetRatingScore_CountryPrefixedList_UsesFirstResolvingEntry(string value, string countryCode, int expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = countryCode });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Fact]
+ public async Task GetRatingScore_ResolvedCountryPrefixedList_DoesNotWarn()
+ {
+ var logger = new Mock<ILogger<LocalizationManager>>();
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object);
+ await localizationManager.LoadAll();
+
+ Assert.NotNull(localizationManager.GetRatingScore("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18"));
+
+ logger.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task GetRatingScore_ListWithoutKnownRating_WarnsOnce()
+ {
+ var logger = new Mock<ILogger<LocalizationManager>>();
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object);
+ await localizationManager.LoadAll();
+
+ Assert.Null(localizationManager.GetRatingScore("DE:Unbekannt / DE:Unsinn"));
+
+ logger.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ [Theory]
[InlineData("-NO RATING SHOWN-")]
[InlineData(":NO RATING SHOWN:")]
public async Task GetRatingLevel_Split_Success(string value)
@@ -525,12 +619,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase));
}
- private LocalizationManager Setup(ServerConfiguration config)
+ private LocalizationManager Setup(ServerConfiguration config, ILogger<LocalizationManager>? logger = null)
{
var mockConfiguration = new Mock<IServerConfigurationManager>();
mockConfiguration.SetupGet(x => x.Configuration).Returns(config);
- return new LocalizationManager(mockConfiguration.Object, new NullLogger<LocalizationManager>());
+ return new LocalizationManager(mockConfiguration.Object, logger ?? new NullLogger<LocalizationManager>());
}
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
index a07e79baa3..13081dcff9 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
@@ -34,7 +34,7 @@ public class PlayCommandQueueTests : IDisposable
/// folder, so the queue query cannot exclude it. Such an item has no media sources, and a
/// client that reaches it in the queue gets an error instead of the next track.
/// </summary>
- /// <returns><placeholder>A <see cref="Task"/> representing the asynchronous unit test.</placeholder></returns>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems()
{
diff --git a/tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs b/tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs
new file mode 100644
index 0000000000..a6ebc82425
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs
@@ -0,0 +1,118 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using System.Xml.Serialization;
+using Emby.Server.Implementations;
+using Jellyfin.Server.Migrations.PreStartupRoutines;
+using Jellyfin.Server.Migrations.Routines;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+public sealed class FixNullEncoderPresetTests : IDisposable
+{
+ private readonly string _configurationDirectory;
+ private readonly string _encodingConfigurationPath;
+ private readonly FixNullEncoderPreset _migration;
+
+ public FixNullEncoderPresetTests()
+ {
+ _configurationDirectory = Directory.CreateTempSubdirectory("jellyfin-migration-test-").FullName;
+ _encodingConfigurationPath = Path.Combine(_configurationDirectory, "encoding.xml");
+
+ var applicationPaths = new Mock<IApplicationPaths>();
+ applicationPaths.SetupGet(paths => paths.ConfigurationDirectoryPath).Returns(_configurationDirectory);
+ _migration = new FixNullEncoderPreset(applicationPaths.Object, NullLogger<FixNullEncoderPreset>.Instance);
+ }
+
+ [Fact]
+ public async Task PerformAsync_ReplacesNullEncoderPresetWithAuto()
+ {
+ const string Configuration = """
+ <?xml version="1.0" encoding="utf-8"?>
+ <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <HardwareAccelerationType>qsv</HardwareAccelerationType>
+ <EncoderPreset xsi:nil="true" />
+ <EnableHardwareEncoding>true</EnableHardwareEncoding>
+ </EncodingOptions>
+ """;
+ await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken);
+
+ await _migration.PerformAsync(TestContext.Current.CancellationToken);
+
+ var serializer = new XmlSerializer(typeof(EncodingOptions));
+ using var reader = File.OpenRead(_encodingConfigurationPath);
+ var encodingOptions = Assert.IsType<EncodingOptions>(serializer.Deserialize(reader));
+ Assert.Equal(EncoderPreset.auto, encodingOptions.EncoderPreset);
+ Assert.Equal(HardwareAccelerationType.qsv, encodingOptions.HardwareAccelerationType);
+ Assert.True(encodingOptions.EnableHardwareEncoding);
+ }
+
+ [Fact]
+ public async Task PerformAsync_LeavesValidEncoderPresetUnchanged()
+ {
+ const string Configuration = """
+ <?xml version="1.0" encoding="utf-8"?>
+ <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <EncoderPreset>fast</EncoderPreset>
+ </EncodingOptions>
+ """;
+ await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken);
+
+ await _migration.PerformAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(Configuration, await File.ReadAllTextAsync(_encodingConfigurationPath, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task PerformAsync_LeavesMalformedConfigurationUnchanged()
+ {
+ const string Configuration = "<EncodingOptions><EncoderPreset xsi:nil=\"true\" />";
+ await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken);
+
+ await _migration.PerformAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(Configuration, await File.ReadAllTextAsync(_encodingConfigurationPath, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task MigrateEncodingOptions_UsesAutoForNullEncoderPreset()
+ {
+ const string Configuration = """
+ <?xml version="1.0" encoding="utf-8"?>
+ <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <HardwareAccelerationType>qsv</HardwareAccelerationType>
+ <TonemappingRange>tv</TonemappingRange>
+ <EncoderPreset xsi:nil="true" />
+ <EnableHardwareEncoding>true</EnableHardwareEncoding>
+ </EncodingOptions>
+ """;
+ await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken);
+ var applicationPaths = new ServerApplicationPaths(
+ _configurationDirectory,
+ _configurationDirectory,
+ _configurationDirectory,
+ _configurationDirectory,
+ _configurationDirectory);
+
+ var migration = new MigrateEncodingOptions(applicationPaths, NullLoggerFactory.Instance);
+ migration.Perform();
+
+ var serializer = new XmlSerializer(typeof(EncodingOptions));
+ using var reader = File.OpenRead(_encodingConfigurationPath);
+ var encodingOptions = Assert.IsType<EncodingOptions>(serializer.Deserialize(reader));
+ Assert.Equal(EncoderPreset.auto, encodingOptions.EncoderPreset);
+ Assert.Equal(HardwareAccelerationType.qsv, encodingOptions.HardwareAccelerationType);
+ Assert.True(encodingOptions.EnableHardwareEncoding);
+ }
+
+ public void Dispose()
+ {
+ Directory.Delete(_configurationDirectory, true);
+ }
+}