diff options
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'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'Onofrio\"", "Vincent D'Onofrio")] + [InlineData("\"Alan Moore 'Swamp Thing'\"", "Alan Moore 'Swamp Thing'")] + [InlineData("\"Tom & Jerry\"", "Tom & Jerry")] + [InlineData("\"Mail <a@b.com> 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'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'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); + } +} |
