diff options
238 files changed, 12975 insertions, 1189 deletions
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 7a00dedbff..12ee6411d3 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,12 +87,9 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: + - 12.1 + - 12.0 - 10.11.11 - - 10.11.10 - - 10.11.9 - - 10.11.8 - - 10.11.7 - - 10.11.6 - Master - Unstable - Older* diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index 9a9f3214a7..5d7dd09348 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -1,66 +1,29 @@ name: Project Automation on: - push: - branches: - - master pull_request: - issue_comment: + types: [opened, reopened, synchronize, ready_for_review, edited] + pull_request_review: + types: [submitted] + pull_request_target: + types: [closed] + +permissions: + contents: read -permissions: {} jobs: - project: - name: Project board + project-board: + name: Release project board runs-on: ubuntu-latest - if: ${{ github.repository == 'jellyfin/jellyfin' }} + if: ${{ github.repository == 'jellyfin/jellyfin' && (github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository) }} steps: - - name: Remove from 'Current Release' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') - continue-on-error: true - with: - project: Current Release - action: delete - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' - continue-on-error: true - with: - project: Release Next - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') - continue-on-error: true - with: - project: Current Release - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Check number of comments from the team member - if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' - id: member_comments - run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - - - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 - continue-on-error: true - with: - project: Issue Triage for Main Repo - column: Needs triage - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: github.event.issue.pull_request == '' && github.event.action == 'opened' - continue-on-error: true + - name: Update project board + uses: jellyfin/jellyfin-packaging/.github/actions/project-automation@master with: - project: Issue Triage for Main Repo - column: Pending response - repo-token: ${{ secrets.JF_BOT_TOKEN }} + github-token: ${{ secrets.GH_PROJECTS_TOKEN }} + repo: jellyfin + event-name: ${{ github.event_name }} + base-ref: ${{ github.event.pull_request.base.ref }} + pr-number: ${{ github.event.pull_request.number }} + pr-merged: ${{ github.event.pull_request.merged }} + required-approvals: '2' diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 99f24e3a14..f5f844bcbc 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -138,6 +138,7 @@ - [SegiH](https://github.com/SegiH) - [SenorSmartyPants](https://github.com/SenorSmartyPants) - [shemanaev](https://github.com/shemanaev) + - [SimonvBez](https://github.com/SimonvBez) - [skaro13](https://github.com/skaro13) - [sl1288](https://github.com/sl1288) - [Smith00101010](https://github.com/Smith00101010) @@ -240,6 +241,7 @@ - [Florin-Popescu](https://github.com/Florin-Popescu) - [m0g3r](https://github.com/m0g3r) - [martin-77](https://github.com/martin-77) + - [Oggeb1](https://github.com/Oggeb1) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index 4d5f0efa9e..38cf513124 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ <PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" /> <PackageVersion Include="AutoFixture" Version="4.18.1" /> <PackageVersion Include="BDInfo" Version="0.8.0" /> - <PackageVersion Include="BitFaster.Caching" Version="2.6.0" /> + <PackageVersion Include="BitFaster.Caching" Version="2.6.1" /> <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="CommandLineParser" Version="2.9.1" /> diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 9f98970df5..acf43a69c2 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -36,7 +36,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Naming</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 8e7da5db42..1f16161282 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles } var extension = Path.GetExtension(path.AsSpan()); - if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + + // .idx carries VobSub per-track language metadata. Recognize it here rather + // than adding it to NamingOptions.SubtitleFileExtensions, which also gates + // subtitle uploads/saves. + var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase); + + if (!isVobSubIndex + && !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) && !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) { diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index 0c737964b4..f06aa909ba 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -158,7 +158,9 @@ namespace Emby.Naming.TV if (nextIndex >= name.Length || !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal)) { - if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + // A range cannot end before it starts, so a lower number belongs to the episode title rather than to a range. + if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num) + && num >= result.EpisodeNumber) { result.EndingEpisodeNumber = num; } @@ -226,7 +228,7 @@ namespace Emby.Naming.TV info.SeriesName = result.SeriesName; } - if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue) + if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber) { info.EndingEpisodeNumber = result.EndingEpisodeNumber; } diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index 733e2418c2..ce42cb69fb 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -10,11 +10,14 @@ namespace Emby.Naming.TV public static partial class SeriesResolver { /// <summary> - /// Regex that matches strings of at least 2 characters separated by a dot or underscore. - /// Used for removing separators between words, i.e turns "The_show" into "The show" while - /// preserving names like "S.H.O.W". + /// Regex that matches a run of dots or underscores that separates two words, where a word is + /// at least 2 characters long. Used for removing separators between words, i.e turns + /// "The_show" into "The show" while preserving acronyms like "S.H.O.W", whose single letters + /// are a word on neither side. Whitespace bounds a word too, so the dot in + /// "Marvel's Agents of S.H.I.E.L.D." is read against the "S" beside it rather than against + /// the whole run of words before it. /// </summary> - [GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")] + [GeneratedRegex(@"(?<=[^\s\._]{2})[\._]+|[\._]+(?=[^\s\._]{2})")] private static partial Regex SeriesNameRegex(); /// <summary> @@ -60,7 +63,7 @@ namespace Emby.Naming.TV if (!string.IsNullOrEmpty(seriesName)) { - seriesName = SeriesNameRegex().Replace(seriesName, "${a} ${b}").Trim(); + seriesName = SeriesNameRegex().Replace(seriesName, " ").Trim(); } return new SeriesInfo(path) diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 29330b132d..e16562774d 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -217,24 +217,7 @@ namespace Emby.Naming.Video for (var i = 0; i < videos.Count; i++) { var video = videos[i]; - var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false); - string? key = null; - if (episodeResult.Success) - { - if (episodeResult.IsByDate - && episodeResult.Year.HasValue - && episodeResult.Month.HasValue - && episodeResult.Day.HasValue) - { - key = FormattableString.Invariant( - $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}"); - } - else if (episodeResult.EpisodeNumber.HasValue) - { - key = FormattableString.Invariant( - $"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}"); - } - } + var key = GetEpisodeVersionKey(video.Files[0].Path); if (key is null) { @@ -265,6 +248,29 @@ namespace Emby.Naming.Video return result; } + private string? GetEpisodeVersionKey(string path) + { + // Optimistic expressions are guesses, so they are not consulted here: merging is destructive, + // a file collapsed into the alternate versions of another one is no longer an episode of its own. + var episodeResult = _episodePathParser.Parse(path, false, isOptimistic: false, fillExtendedInfo: false); + if (!episodeResult.Success) + { + return null; + } + + if (episodeResult.IsByDate) + { + return episodeResult.Year.HasValue && episodeResult.Month.HasValue && episodeResult.Day.HasValue + ? FormattableString.Invariant( + $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}") + : null; + } + + return episodeResult.SeasonNumber.HasValue && episodeResult.EpisodeNumber.HasValue + ? FormattableString.Invariant($"S{episodeResult.SeasonNumber.Value}E{episodeResult.EpisodeNumber.Value}") + : null; + } + private static VideoInfo OrganizeAlternateVersions( List<VideoInfo> videos, VideoInfo? primaryOverride = null, diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 84d50f5121..cd93f76d26 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -9,6 +9,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; @@ -114,7 +115,7 @@ namespace Emby.Server.Implementations.Collections _libraryManager.RootFolder.Children = null; - return FindFolders(path).First(); + return FindFolders(path).FirstOrDefault(); } internal string GetCollectionsFolderPath() @@ -167,7 +168,7 @@ namespace Emby.Server.Implementations.Collections if (parentFolder is null) { - throw new ArgumentException(nameof(parentFolder)); + throw new InvalidOperationException("Unable to resolve the collections library folder, so the collection cannot be created."); } var path = Path.Combine(parentFolder.Path, folderName); @@ -235,7 +236,7 @@ namespace Emby.Server.Implementations.Collections List<BaseItem>? itemList = null; - var linkedChildrenList = collection.GetLinkedChildren(); + var linkedChildrenList = collection.GetLinkedChildren(DtoOptions.StoredColumnsOnly); var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList(); foreach (var id in ids) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index a2d3e14439..e539508644 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; @@ -185,15 +186,18 @@ namespace Emby.Server.Implementations.Dto allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); } - // Batch-fetch child counts for all folders to avoid N+1 queries + // Batch-fetch by-name item counts to avoid N+1 queries + Dictionary<Guid, ItemCounts>? itemCountsBatch = null; + if (options.ContainsField(ItemFields.ItemCounts)) + { + itemCountsBatch = GetItemCountsBatch(accessibleItems, user); + } + + // Batch-fetch child counts for all folders to avoid N+1 queries. Dictionary<Guid, int>? childCountBatch = null; - if (options.ContainsField(ItemFields.ChildCount)) + if (user is not null && options.ContainsField(ItemFields.ChildCount)) { - var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList(); - if (folderIds.Count > 0) - { - childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user); - } + childCountBatch = GetChildCountBatch(accessibleItems, user); } // Batch-fetch played/total counts for all folders to avoid N+1 queries @@ -293,7 +297,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ItemCounts)) { - SetItemByNameInfo(dto, user); + SetItemByNameInfo(dto, user, itemCountsBatch); } returnItems[index] = dto; @@ -518,14 +522,36 @@ namespace Emby.Server.Implementations.Dto return dto; } - private void SetItemByNameInfo(BaseItemDto dto, User? user) + private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user) + { + var result = new Dictionary<Guid, ItemCounts>(); + + foreach (var group in items.GroupBy(item => item.GetBaseItemKind())) + { + if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds)) + { + continue; + } + + var ids = group.Select(item => item.Id).ToArray(); + foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user)) + { + result[id] = counts; + } + } + + return result; + } + + private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null) { if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds)) { return; } - var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); + var counts = prefetchedCounts?.GetValueOrDefault(dto.Id) + ?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); dto.AlbumCount = counts.AlbumCount; dto.ArtistCount = counts.ArtistCount; @@ -685,24 +711,102 @@ namespace Emby.Server.Implementations.Dto }; } - private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch) + private Dictionary<Guid, int>? GetChildCountBatch(IReadOnlyList<BaseItem> items, User user) { - // Right now this is too slow to calculate for top level folders on a per-user basis - // Just return something so that apps that are expecting a value won't think the folders are empty - if (folder is ICollectionFolder || folder is UserView) + Dictionary<Guid, IReadOnlyList<Guid>>? sources = null; + foreach (var folder in items.OfType<Folder>()) + { + var sourceIds = GetChildCountSourceIds(folder, user); + if (sourceIds.Count > 0) + { + (sources ??= new Dictionary<Guid, IReadOnlyList<Guid>>())[folder.Id] = sourceIds; + } + } + + if (sources is null) { - return Random.Shared.Next(1, 10); + return null; } + var counts = _libraryManager.GetChildCountBatch( + sources.Values.SelectMany(ids => ids).Distinct().ToList(), + user); + + var result = new Dictionary<Guid, int>(sources.Count); + foreach (var (folderId, sourceIds) in sources) + { + var total = 0; + foreach (var sourceId in sourceIds) + { + total += counts.GetValueOrDefault(sourceId); + } + + result[folderId] = total; + } + + return result; + } + + private IReadOnlyList<Guid> GetChildCountSourceIds(Folder folder, User user) + { + if (folder is CollectionFolder collectionFolder) + { + return collectionFolder.PhysicalFolderIds; + } + + if (folder is not UserView view) + { + return [folder.Id]; + } + + // Only a view that stands for a library proxies it. The sub-views a movie or show view + // is built from hang off the same library but hold a query, not the library's children. + if (!UserView.EnableOriginalFolder(view.ViewType) + && view.ViewType is not (CollectionType.movies or CollectionType.tvshows)) + { + return []; + } + + // A view over a single library proxies that library, whatever the view type. + var parentId = view.DisplayParentId.IsEmpty() ? view.ParentId : view.DisplayParentId; + if (!parentId.IsEmpty() + && !parentId.Equals(view.Id) + && _libraryManager.GetItemById(parentId) is Folder parent + && parent is not UserView) + { + return GetChildCountSourceIds(parent, user); + } + + // A grouped view has no single parent: it stands for every library the user grouped + // into it, the same set UserViewManager builds the view from. + if (view.ViewType is CollectionType.movies or CollectionType.tvshows) + { + return _libraryManager.GetUserRootFolder() + .GetChildren(user, true) + .OfType<CollectionFolder>() + .Where(f => user.IsFolderGrouped(f.Id) + && (f.CollectionType == view.ViewType || f.CollectionType is null)) + .SelectMany(f => f.PhysicalFolderIds) + .Distinct() + .ToList(); + } + + return []; + } + + private int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch) + { // Use pre-fetched batch data if available if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count)) { return count; } - // Only reached when no batch was computed: the batch holds an entry for every folder it - // was asked about, zero included. - return folder.GetChildCount(user); + // No batch covered this folder. + var single = GetChildCountBatch([folder], user); + return single is not null && single.TryGetValue(folder.Id, out var singleCount) + ? singleCount + : folder.GetChildCount(user); } private static void SetBookProperties(BaseItemDto dto, Book item) diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 933cfc8cbe..02b104756e 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints; /// </summary> public sealed class LibraryChangedNotifier : IHostedService, IDisposable { + // A batch holds a live reference to every item it names, so it has to stay small enough that a + // library scan - which changes items faster than any batch window closes - cannot grow it without + // bound. Reached only by a scan; interactive use closes a batch on the window long before this. + internal const int MaxBatchSize = 2000; + private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IProviderManager _providerManager; @@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private readonly ILogger<LibraryChangedNotifier> _logger; private readonly Lock _libraryChangedSyncLock = new(); - private readonly List<Folder> _foldersAddedTo = new(); - private readonly List<Folder> _foldersRemovedFrom = new(); - private readonly List<BaseItem> _itemsAdded = new(); - private readonly List<BaseItem> _itemsRemoved = new(); - private readonly List<BaseItem> _itemsUpdated = new(); + private readonly Dictionary<Guid, Folder> _foldersAddedTo = []; + private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = []; + private readonly Dictionary<Guid, BaseItem> _itemsAdded = []; + private readonly Dictionary<Guid, BaseItem> _itemsRemoved = []; + private readonly Dictionary<Guid, BaseItem> _itemsUpdated = []; private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new(); private Timer? _libraryUpdateTimer; @@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e) => OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom); - private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList) + private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList) { if (!FilterItem(item)) { @@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable lock (_libraryChangedSyncLock) { - var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); - + // The window runs from the first change of a batch and is never extended. Extending it on + // every change would keep a library scan's batch open for the whole scan, and the batch + // holds the items it names alive, so it would grow to the size of the library. if (_libraryUpdateTimer is null) { + var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); _libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan); } - else - { - _libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan); - } if (foldersList is not null && parent is Folder folder) { - foldersList.Add(folder); + foldersList[folder.Id] = folder; } - itemsList.Add(item); + itemsList[item.Id] = item; + + // A window long enough to cover a burst still has to give way once the batch is large + // enough to be worth sending on its own. + if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize) + { + _libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } } } @@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable List<BaseItem> itemsRemoved; lock (_libraryChangedSyncLock) { - // Remove dupes in case some were saved multiple times - foldersAddedTo = _foldersAddedTo - .DistinctBy(x => x.Id) - .ToList(); - - foldersRemovedFrom = _foldersRemovedFrom - .DistinctBy(x => x.Id) - .ToList(); + foldersAddedTo = _foldersAddedTo.Values.ToList(); + foldersRemovedFrom = _foldersRemovedFrom.Values.ToList(); itemsUpdated = _itemsUpdated - .Where(i => !_itemsAdded.Contains(i)) - .DistinctBy(x => x.Id) + .Where(e => !_itemsAdded.ContainsKey(e.Key)) + .Select(e => e.Value) .ToList(); - itemsAdded = _itemsAdded.ToList(); - itemsRemoved = _itemsRemoved.ToList(); + itemsAdded = _itemsAdded.Values.ToList(); + itemsRemoved = _itemsRemoved.Values.ToList(); if (_libraryUpdateTimer is not null) { @@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable _foldersRemovedFrom.Clear(); } + if (itemsAdded.Count == 0 + && itemsUpdated.Count == 0 + && itemsRemoved.Count == 0 + && foldersAddedTo.Count == 0 + && foldersRemovedFrom.Count == 0) + { + return; + } + await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false); } diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index fc174b7c14..b182e5837b 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints public sealed class UserDataChangeNotifier : IHostedService, IDisposable { private const int UpdateDuration = 500; + internal const int MaxBatchSize = 2000; private readonly ISessionManager _sessionManager; private readonly IUserDataManager _userDataManager; private readonly IUserManager _userManager; - private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new(); + private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = []; private readonly Lock _syncLock = new(); private Timer? _updateTimer; + private int _changedItemCount; /// <summary> /// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class. @@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints lock (_syncLock) { - if (_updateTimer is null) + // The window runs from the first change of a batch and is never extended, so a stream + // of changes that never pauses - a library scan - still closes its batches instead of + // holding every item it touched alive until the stream stops. + _updateTimer ??= new Timer( + UpdateTimerCallback, + null, + UpdateDuration, + Timeout.Infinite); + + if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys)) { - _updateTimer = new Timer( - UpdateTimerCallback, - null, - UpdateDuration, - Timeout.Infinite); - } - else - { - _updateTimer.Change(UpdateDuration, Timeout.Infinite); - } - - if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys)) - { - keys = new List<BaseItem>(); + keys = []; _changedItems[e.UserId] = keys; } - keys.Add(e.Item); - var baseItem = e.Item; // Go up one level for indicators if (baseItem is not null) { + Track(keys, baseItem); + var parent = baseItem.GetOwner() ?? baseItem.GetParent(); if (parent is not null) { - keys.Add(parent); + Track(keys, parent); } } + + // A window long enough to cover a burst still has to give way once the batch is + // large enough to be worth sending on its own. + if (_changedItemCount >= MaxBatchSize) + { + _updateTimer.Change(0, Timeout.Infinite); + } + } + } + + private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item) + { + var before = keys.Count; + keys[item.Id] = item; + + if (keys.Count != before) + { + _changedItemCount++; } } private async void UpdateTimerCallback(object? state) { - List<KeyValuePair<Guid, List<BaseItem>>> changes; + List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes; lock (_syncLock) { - // Remove dupes in case some were saved multiple times changes = _changedItems.ToList(); _changedItems.Clear(); + _changedItemCount = 0; if (_updateTimer is not null) { @@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints } } + if (changes.Count == 0) + { + return; + } + foreach (var (userId, changedItems) in changes) { await _sessionManager.SendMessageToUserSessions( [userId], SessionMessageType.UserDataChanged, - () => GetUserDataChangeInfo(userId, changedItems), + () => GetUserDataChangeInfo(userId, changedItems.Values), default).ConfigureAwait(false); } } - private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems) + private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems) { var user = _userManager.GetUserById(userId) ?? throw new ArgumentException("Invalid user ID", nameof(userId)); @@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints { UserId = userId, UserDataList = changedItems - .DistinctBy(x => x.Id) .Select(i => { var dto = _userDataManager.GetUserDataDto(i, user); diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index dc7f972c13..d7319f80f4 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -127,7 +127,7 @@ namespace Emby.Server.Implementations.HttpServer { receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException) + catch (Exception ex) when (IsConnectionGone(ex)) { // ObjectDisposedException/OperationCanceledException: the socket was torn // down underneath us (e.g. by the keep-alive watchdog after the connection @@ -158,7 +158,15 @@ namespace Emby.Server.Implementations.HttpServer if (receiveResult.EndOfMessage) { - await ProcessInternal(pipe.Reader).ConfigureAwait(false); + try + { + await ProcessInternal(pipe.Reader).ConfigureAwait(false); + } + catch (Exception ex) when (IsConnectionGone(ex)) + { + _logger.LogWarning("WS {IP} error sending data: {Message}", RemoteEndPoint, ex.Message); + break; + } } } while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting) @@ -170,13 +178,24 @@ namespace Emby.Server.Implementations.HttpServer || _socket.State == WebSocketState.CloseReceived || _socket.State == WebSocketState.CloseSent) { - await _socket.CloseAsync( - WebSocketCloseStatus.NormalClosure, - string.Empty, - cancellationToken).ConfigureAwait(false); + try + { + await _socket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + string.Empty, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsConnectionGone(ex)) + { + // The peer is already gone, there is nobody left to send the close frame to. + _logger.LogDebug("WS {IP} error closing connection: {Message}", RemoteEndPoint, ex.Message); + } } } + private static bool IsConnectionGone(Exception ex) + => ex is WebSocketException or ObjectDisposedException or OperationCanceledException; + private async Task ProcessInternal(PipeReader reader) { ReadResult result = await reader.ReadAsync().ConfigureAwait(false); diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index f634084034..b31cf0f1f5 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -109,6 +109,11 @@ namespace Emby.Server.Implementations.IO lock (_timerLock) { + if (_disposed) + { + return; + } + paths = _affectedPaths.ToList(); } @@ -129,11 +134,12 @@ namespace Emby.Server.Implementations.IO private void ProcessPathChanges(List<string> paths) { - IEnumerable<BaseItem> itemsToRefresh = paths + var itemsToRefresh = paths .Distinct() - .Select(GetAffectedBaseItem) - .Where(item => item is not null) - .DistinctBy(x => x!.Id)!; // Removed null values in the previous .Where() + .Select(TryGetAffectedBaseItem) + .OfType<BaseItem>() + .DistinctBy(x => x.Id) + .ToList(); foreach (var item in itemsToRefresh) { @@ -155,6 +161,19 @@ namespace Emby.Server.Implementations.IO } } + private BaseItem? TryGetAffectedBaseItem(string path) + { + try + { + return GetAffectedBaseItem(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding the item affected by changes to {Path}", path); + return null; + } + } + /// <summary> /// Gets the affected base item. /// </summary> diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 1bf0f8c76c..e51c863f86 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -3,11 +3,13 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -21,6 +23,7 @@ namespace Emby.Server.Implementations.IO private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; + private readonly IDirectoryService _directoryService; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; /// <summary> @@ -38,6 +41,12 @@ namespace Emby.Server.Implementations.IO /// </summary> private readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new(StringComparer.OrdinalIgnoreCase); + /// <summary> + /// Incremented by every <see cref="Stop"/> so watchers still being created on a background + /// task can tell that the sweep they should have been caught by has already run. + /// </summary> + private int _watcherGeneration; + private bool _disposed; /// <summary> @@ -47,6 +56,7 @@ namespace Emby.Server.Implementations.IO /// <param name="libraryManager">The library manager.</param> /// <param name="configurationManager">The configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> + /// <param name="directoryService">The directory service.</param> /// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> public LibraryMonitor( @@ -54,6 +64,7 @@ namespace Emby.Server.Implementations.IO ILibraryManager libraryManager, IServerConfigurationManager configurationManager, IFileSystem fileSystem, + IDirectoryService directoryService, IHostApplicationLifetime appLifetime, DotIgnoreIgnoreRule dotIgnoreIgnoreRule) { @@ -61,10 +72,11 @@ namespace Emby.Server.Implementations.IO _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; + _directoryService = directoryService; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; appLifetime.ApplicationStarted.Register(Start); - appLifetime.ApplicationStopping.Register(Stop); + appLifetime.ApplicationStopping.Register(Dispose); } /// <inheritdoc /> @@ -115,6 +127,11 @@ namespace Emby.Server.Implementations.IO /// <inheritdoc /> public void Start() { + if (_disposed) + { + return; + } + _libraryManager.ItemAdded += OnLibraryManagerItemAdded; _libraryManager.ItemRemoved += OnLibraryManagerItemRemoved; @@ -228,6 +245,8 @@ namespace Emby.Server.Implementations.IO return; } + var generation = Volatile.Read(ref _watcherGeneration); + // Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do them all in parallel Task.Run(() => { @@ -251,7 +270,11 @@ namespace Emby.Server.Implementations.IO newWatcher.Changed += OnWatcherChanged; newWatcher.Error += OnWatcherError; - if (_fileSystemWatchers.TryAdd(path, newWatcher)) + if (_disposed || Volatile.Read(ref _watcherGeneration) != generation) + { + DisposeWatcher(newWatcher, false); + } + else if (_fileSystemWatchers.TryAdd(path, newWatcher)) { newWatcher.EnableRaisingEvents = true; _logger.LogInformation("Watching directory {Path}", path); @@ -352,6 +375,11 @@ namespace Emby.Server.Implementations.IO { ArgumentException.ThrowIfNullOrEmpty(path); + if (_disposed) + { + return; + } + if (IgnorePatterns.ShouldIgnore(path)) { return; @@ -363,6 +391,8 @@ namespace Emby.Server.Implementations.IO return; } + _directoryService.Invalidate(path); + // Ignore certain files, If the parent of an ignored path has a change event, ignore that too foreach (var i in _tempIgnoredPaths.Keys) { @@ -445,6 +475,8 @@ namespace Emby.Server.Implementations.IO /// </summary> public void Stop() { + Interlocked.Increment(ref _watcherGeneration); + _libraryManager.ItemAdded -= OnLibraryManagerItemAdded; _libraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; @@ -489,8 +521,9 @@ namespace Emby.Server.Implementations.IO return; } - Stop(); + // Set before stopping so anything racing us stops handing out new work. _disposed = true; + Stop(); } } } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index ede9b27592..db743c8d31 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -489,11 +489,18 @@ namespace Emby.Server.Implementations.IO ArgumentException.ThrowIfNullOrEmpty(parentPath); ArgumentException.ThrowIfNullOrEmpty(path); - return path.Contains( - Path.TrimEndingDirectorySeparator(parentPath) + Path.DirectorySeparatorChar, - _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + var parent = Path.TrimEndingDirectorySeparator(parentPath); + + // The parent has to be an anchored prefix of the path, otherwise unrelated paths that merely + // contain the parent as a segment (e.g. /media and /data/media/tv) would be treated as related. + return path.Length > parent.Length + && path.StartsWith(parent, _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) + && (Path.EndsInDirectorySeparator(parent) || IsDirectorySeparator(path[parent.Length])); } + private static bool IsDirectorySeparator(char c) + => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + /// <inheritdoc /> public virtual bool AreEqual(string path1, string path2) { diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 3db8265f6e..caba304888 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -15,7 +16,6 @@ using Emby.Naming.Common; using Emby.Naming.TV; using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers; -using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks.Tasks; using Emby.Server.Implementations.Sorting; @@ -35,7 +35,6 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; @@ -75,7 +74,6 @@ namespace Emby.Server.Implementations.Library private readonly Lazy<IProviderManager> _providerManagerFactory; private readonly Lazy<IUserViewManager> _userViewManagerFactory; private readonly IServerApplicationHost _appHost; - private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; private readonly IItemRepository _itemRepository; private readonly IItemPersistenceService _persistenceService; @@ -88,6 +86,7 @@ namespace Emby.Server.Implementations.Library private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly FastConcurrentLru<Guid, BaseItem> _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; @@ -122,7 +121,6 @@ namespace Emby.Server.Implementations.Library /// <param name="fileSystem">The file system.</param> /// <param name="providerManagerFactory">The provider manager.</param> /// <param name="userViewManagerFactory">The user view manager.</param> - /// <param name="mediaEncoder">The media encoder.</param> /// <param name="itemRepository">The item repository.</param> /// <param name="persistenceService">The item persistence service.</param> /// <param name="nextUpService">The next up service.</param> @@ -148,7 +146,6 @@ namespace Emby.Server.Implementations.Library IFileSystem fileSystem, Lazy<IProviderManager> providerManagerFactory, Lazy<IUserViewManager> userViewManagerFactory, - IMediaEncoder mediaEncoder, IItemRepository itemRepository, IItemPersistenceService persistenceService, INextUpService nextUpService, @@ -174,7 +171,6 @@ namespace Emby.Server.Implementations.Library _fileSystem = fileSystem; _providerManagerFactory = providerManagerFactory; _userViewManagerFactory = userViewManagerFactory; - _mediaEncoder = mediaEncoder; _itemRepository = itemRepository; _persistenceService = persistenceService; _nextUpService = nextUpService; @@ -189,6 +185,7 @@ namespace Emby.Server.Implementations.Library _pathManager = pathManager; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; _localization = localization; + _directoryService = directoryService; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -321,7 +318,7 @@ namespace Emby.Server.Implementations.Library if (wizardChanged) { - _taskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(); + QueueLibraryScan(); } } @@ -881,7 +878,18 @@ namespace Emby.Server.Implementations.Library wrongTypeItem.GetType().Name, expectedVideoType.Name, path); - DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false }); + + // A full DeleteItem would save the primary version, which resolves its + // alternates again and re-enters here before this row is gone. + DeleteItemsUnsafeFast([wrongTypeItem]); + + // The fast path skips the parent bookkeeping, and the stale item is listed + // under its ParentId, so that folder's cached listing has to be dropped. + if (wrongTypeItem.GetParent() is Folder staleParent) + { + staleParent.Children = null; + staleParent.UserData = null; + } } } @@ -1210,6 +1218,12 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public Guid GetPersonId(string name) + { + return GetItemByNameId<Person>(Person.GetPath(name)); + } + + /// <inheritdoc /> public Person? GetPerson(string name) { var path = Person.GetPath(name); @@ -1222,6 +1236,33 @@ namespace Emby.Server.Implementations.Library return null; } + /// <inheritdoc /> + public Person GetOrCreatePerson(string name) + { + var existing = GetPerson(name); + if (existing is not null) + { + return existing; + } + + var path = Person.GetPath(name); + var info = Directory.CreateDirectory(path); + var item = new Person + { + Name = name, + Id = GetItemByNameId<Person>(path), + DateCreated = info.CreationTimeUtc, + DateModified = info.LastWriteTimeUtc, + Path = path + }; + + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + CreateItem(item, null); + + return item; + } + /// <summary> /// Gets the studio. /// </summary> @@ -1354,15 +1395,6 @@ namespace Emby.Server.Implementations.Library return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId); } - /// <inheritdoc /> - public Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - // Ensure the location is available. - Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath); - - return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress); - } - /// <summary> /// Reloads the root media folder. /// </summary> @@ -1489,6 +1521,10 @@ namespace Emby.Server.Implementations.Library var numComplete = 0; var numTasks = tasks.Count; + _logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks); + + var phaseStart = Stopwatch.GetTimestamp(); + foreach (var task in tasks) { // Prevent access to modified closure @@ -1506,20 +1542,45 @@ namespace Emby.Server.Implementations.Library progress.Report(innerPercent); }); - _logger.LogDebug("Running post-scan task {0}", task.GetType().Name); + var taskName = task.GetType().Name; + var taskStart = Stopwatch.GetTimestamp(); + + _logger.LogInformation( + "Running post-scan task {TaskNumber}/{TaskCount}: {TaskName}", + currentNumComplete + 1, + numTasks, + taskName); try { await task.Run(innerProgress, cancellationToken).ConfigureAwait(false); + + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogInformation( + "Post-scan task {TaskName} completed after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); } catch (OperationCanceledException) { - _logger.LogInformation("Post-scan task cancelled: {0}", task.GetType().Name); + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogInformation( + "Post-scan task {TaskName} cancelled after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error running post-scan task"); + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogError( + ex, + "Post-scan task {TaskName} failed after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); } numComplete++; @@ -1528,6 +1589,12 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } + var phaseElapsed = Stopwatch.GetElapsedTime(phaseStart); + _logger.LogInformation( + "All post-scan tasks completed after {Minutes} minute(s) and {Seconds} seconds", + Math.Truncate(phaseElapsed.TotalMinutes), + phaseElapsed.Seconds); + _persistenceService.UpdateInheritedValues(); progress.Report(100); @@ -1745,6 +1812,18 @@ namespace Emby.Server.Implementations.Library return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query); } + /// <inheritdoc/> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user) + { + var query = new InternalItemsQuery(user); + if (user is not null) + { + AddUserToQuery(query, user); + } + + return _countService.GetItemCountsForNameItems(kind, ids, relatedItemKinds, query); + } + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user) { return _countService.GetChildCountBatch(parentIds, user); @@ -3720,6 +3799,10 @@ namespace Emby.Server.Implementations.Library AddMediaPathInternal(name, path, false); } } + + // The libraries root was listed before this folder existed, so drop that listing: + // anything still reading it resolves the library set without the new folder. + _directoryService.Invalidate(virtualFolderPath); } finally { @@ -3727,7 +3810,7 @@ namespace Emby.Server.Implementations.Library if (refreshLibrary) { - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -3746,27 +3829,14 @@ namespace Emby.Server.Implementations.Library var itemUpdateType = ItemUpdateType.MetadataDownload; var saveEntity = false; - var createEntity = false; var personEntity = GetPerson(person.Name); if (personEntity is null) { try { - var path = Person.GetPath(person.Name); - var info = Directory.CreateDirectory(path); - personEntity = new Person() - { - Name = person.Name, - Id = GetItemByNameId<Person>(path), - DateCreated = info.CreationTimeUtc, - DateModified = info.LastWriteTimeUtc, - Path = path - }; - - personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); + personEntity = GetOrCreatePerson(person.Name); saveEntity = true; - createEntity = true; } catch (Exception ex) { @@ -3800,11 +3870,6 @@ namespace Emby.Server.Implementations.Library if (saveEntity) { - if (createEntity) - { - CreateItems([personEntity], null, CancellationToken.None); - } - await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); personEntity.DateLastSaved = DateTime.UtcNow; @@ -3813,13 +3878,16 @@ namespace Emby.Server.Implementations.Library } } - private void StartScanInBackground() + internal Task StartScanInBackground() { - Task.Run(() => + // An active scan already handles library structure changes, so this request can be dropped. + if (IsScanRunning) { - // No need to start if scanning the library because it will handle it - ValidateMediaLibrary(new Progress<double>(), CancellationToken.None); - }); + return Task.CompletedTask; + } + + // Queue instead of restarting so a scan that starts after the check is allowed to finish. + return Task.Run(QueueLibraryScan); } public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath) @@ -3920,6 +3988,7 @@ namespace Emby.Server.Implementations.Library try { Directory.Delete(path, true); + _directoryService.Invalidate(path); } finally { @@ -3929,7 +3998,7 @@ namespace Emby.Server.Implementations.Library { await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -3989,6 +4058,7 @@ namespace Emby.Server.Implementations.Library if (!string.IsNullOrEmpty(shortcut)) { _fileSystem.DeleteFile(shortcut); + _directoryService.Invalidate(shortcut); } var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -4032,6 +4102,7 @@ namespace Emby.Server.Implementations.Library } _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); + _directoryService.Invalidate(lnk); RemoveContentTypeOverrides(path); } @@ -4071,6 +4142,18 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public IReadOnlyList<string> GetTagNames(InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetTagNames(query); + } + + /// <inheritdoc /> public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) { return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 97e00177b6..e9bba05839 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -384,7 +384,13 @@ namespace Emby.Server.Implementations.Library { ArgumentNullException.ThrowIfNull(item); - var hasMediaSources = (IHasMediaSources)item; + // Clients can ask for the sources of an item that has none (a container queued by mistake). + if (item is not IHasMediaSources hasMediaSources) + { + throw new ArgumentException( + string.Format(CultureInfo.InvariantCulture, "{0} {1} has no media sources and cannot be played.", item.GetType().Name, item.Id), + nameof(item)); + } var sources = hasMediaSources.GetMediaSources(enablePathSubstitution); @@ -494,7 +500,12 @@ namespace Emby.Server.Implementations.Library { var index = userData.SubtitleStreamIndex.Value; // Make sure the saved index is still valid - if (index == -1 || source.MediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && i.Index == index)) + var savedStream = source.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + // "Only forced" rules out full tracks entirely, so a remembered one must not resurrect them. + // The client reports whatever is playing, so an index remembered under another mode sticks forever otherwise. + if (index == -1 + || (savedStream is not null + && (user.SubtitleMode != SubtitlePlaybackMode.OnlyForced || savedStream.IsForced))) { source.DefaultSubtitleStreamIndex = index; return; diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index 306a8673d5..a8ee416b31 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -143,11 +143,19 @@ public class SearchManager : ISearchManager baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); - var allowedIds = await baseQuery - .Select(e => e.Id) - .ToHashSetAsync(cancellationToken) + var allowed = await baseQuery + .Select(e => new { e.Id, e.PrimaryVersionId }) + .ToListAsync(cancellationToken) .ConfigureAwait(false); + var allowedIds = allowed.Select(e => e.Id).ToHashSet(); + + // A provider can return both an alternate version and the primary it belongs to, and the + // two are one item to the user. + allowedIds.ExceptWith(allowed + .Where(e => e.PrimaryVersionId.HasValue && allowedIds.Contains(e.PrimaryVersionId.Value)) + .Select(e => e.Id)); + if (allowedIds.Count == candidates.Count) { return candidates; diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs index c4d3b249d5..2cbfb6a4fa 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -115,6 +115,7 @@ public class SqlSearchProvider : IInternalSearchProvider dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); dbQuery = ApplyParentFilter(dbQuery, query.ParentId); dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query); + dbQuery = ExcludeVersionsOfMatchedPrimaries(dbQuery); // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it @@ -193,6 +194,12 @@ public class SqlSearchProvider : IInternalSearchProvider return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); } + private static IQueryable<BaseItemEntity> ExcludeVersionsOfMatchedPrimaries(IQueryable<BaseItemEntity> query) + { + var matched = query; + return query.Where(e => e.PrimaryVersionId == null || !matched.Any(p => p.Id == e.PrimaryVersionId)); + } + private IQueryable<BaseItemEntity> ApplyUserAccessFilter( JellyfinDbContext dbContext, IQueryable<BaseItemEntity> query, diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 57d1f7c770..cc8f0fd24e 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,3 +1,5 @@ +#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees. + using System; using System.Collections.Generic; using System.Linq; @@ -172,7 +174,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie var allCandidateIdsList = allCandidateIds.ToList(); var accessibleItems = await baseQuery .WhereOneOrMany(allCandidateIdsList, e => e.Id) - .Select(e => new { e.Id, e.PresentationUniqueKey }) + .Select(e => new { e.Id, e.PresentationUniqueKey, e.PrimaryVersionId }) .ToListAsync(cancellationToken).ConfigureAwait(false); // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey @@ -189,6 +191,9 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie var orderedIds = accessibleItems .Where(x => scores.ContainsKey(x.Id)) .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + // Two versions of one movie score the same, so name the primary as the + // representative of the group rather than whichever came back first. + .ThenBy(x => x.PrimaryVersionId.HasValue) .DistinctBy(x => x.PresentationUniqueKey) .Take(limit) .Select(x => x.Id) @@ -245,6 +250,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie result[id] = []; } + var hiddenVersionIds = context.BaseItems.AsNoTracking() + .Where(e => e.PrimaryVersionId != null + && context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId)) + .Select(e => e.Id); + foreach (var (valueType, weight) in _itemValueDimensions) { var sourceRows = await context.ItemValuesMap.AsNoTracking() @@ -260,7 +270,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie } var candidateRows = await context.ItemValuesMap.AsNoTracking() - .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Where(m => !hiddenVersionIds.Contains(m.ItemId) && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) .ToListAsync(cancellationToken).ConfigureAwait(false); @@ -276,6 +286,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie if (personSourceRows.Count > 0) { var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => !hiddenVersionIds.Contains(m.ItemId)) .Where(m => context.PeopleBaseItemMap .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) .Select(s => s.PeopleId) diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index fd5f292ebe..a18a17b593 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -651,7 +651,13 @@ public class SimilarItemsManager : ISimilarItemsManager try { - var stream = File.OpenRead(cachePath); + var stream = new FileStream( + cachePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); await using (stream.ConfigureAwait(false)) { var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); @@ -675,6 +681,7 @@ public class SimilarItemsManager : ISimilarItemsManager private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken) { + string? tempPath = null; try { var directory = Path.GetDirectoryName(cachePath); @@ -689,16 +696,34 @@ public class SimilarItemsManager : ISimilarItemsManager ExpiresAt = DateTime.UtcNow.Add(cacheDuration) }; - var stream = File.Create(cachePath); + tempPath = cachePath + "." + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + ".tmp"; + var stream = File.Create(tempPath); await using (stream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); } + + File.Move(tempPath, cachePath, true); + tempPath = null; } catch (IOException ex) { _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath); } + finally + { + if (tempPath is not null) + { + try + { + File.Delete(tempPath); + } + catch (IOException ex) + { + _logger.LogDebug(ex, "Failed to delete temporary similar items cache file {TempPath}", tempPath); + } + } + } } private sealed class SimilarItemsCache diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 47b3891901..cfb2dd53d3 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -60,17 +60,10 @@ 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) + if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) + && !HasVisibleChild(folder, user)) { - var items = folder.GetItemList(new InternalItemsQuery(user) - { - ParentId = folder.ParentId - }); - - if (!items.Any(item => item.IsVisible(user))) - { - continue; - } + continue; } if (UserView.IsUserSpecific(folder)) @@ -127,7 +120,7 @@ namespace Emby.Server.Implementations.Library list.AddRange(channels); - if (_liveTvManager.GetEnabledUsers().Select(i => i.Id).Contains(user.Id)) + if (_liveTvManager.IsEnabledForUser(user)) { list.Add(_liveTvManager.GetInternalLiveTvFolder(CancellationToken.None)); } @@ -159,6 +152,32 @@ namespace Emby.Server.Implementations.Library .ToArray(); } + private bool HasVisibleChild(Folder folder, 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]; + + foreach (var parentId in parentIds) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + ParentId = parentId, + GroupByPresentationUniqueKey = false, + DtoOptions = DtoOptions.StoredColumnsOnly + }); + + if (items.Any(item => item.IsVisible(user))) + { + return true; + } + } + + return false; + } + public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName) { var uniqueId = parentId + "subview" + type; diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index fa7112eb90..690466be70 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; @@ -61,6 +62,9 @@ public class ArtistsValidator var count = names.Count; var refreshed = 0; + var liveIds = new HashSet<Guid>(); + var unresolved = 0; + foreach (var name in names) { try @@ -73,13 +77,20 @@ public class ArtistsValidator // Fall back to GetArtist if not found (creates new item if needed) item ??= _libraryManager.GetArtist(name); - var isNew = !existingArtistIds.Contains(item.Id); - var neverRefreshed = item.DateLastRefreshed == default; - if (isNew || neverRefreshed) + // A name with no item is nothing to refresh, and nothing to keep alive either. + if (item is not null) { - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); - refreshed++; + liveIds.Add(item.Id); + + var isNew = !existingArtistIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; + + if (isNew || neverRefreshed) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } } catch (OperationCanceledException) @@ -88,6 +99,7 @@ public class ArtistsValidator } catch (Exception ex) { + unresolved++; _logger.LogError(ex, "Error refreshing {ArtistName}", name); } @@ -101,13 +113,26 @@ public class ArtistsValidator _logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count); + // Every name that threw is a name whose artist is missing from the live set, and deleting against + // a live set with holes in it deletes artists the library still refers to. Leave the sweep to a + // run that got a clean read of them. + if (unresolved > 0) + { + _logger.LogWarning( + "Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run", + unresolved, + count); + + progress.Report(100); + return; + } + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicArtist], - IsDeadArtist = true, IsLocked = false - }).Cast<MusicArtist>() - .Where(item => item.IsAccessedByName) + }).OfType<MusicArtist>() + .Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id)) .ToList(); foreach (var item in deadEntities) diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index 078a0b921d..7d53f40ce7 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -1,12 +1,12 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators; @@ -17,112 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators; public class PeopleValidator { /// <summary> - /// The _library manager. + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; /// <summary> - /// The _logger. + /// The logger. /// </summary> - private readonly ILogger _logger; - - private readonly IFileSystem _fileSystem; + private readonly ILogger<PeopleValidator> _logger; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidator" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem) + public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger) { _libraryManager = libraryManager; _logger = logger; - _fileSystem = fileSystem; } /// <summary> /// Validates the people. /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress) + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { // Before the refresh below walks them: a credit no item maps to any more stands for nothing, // and while it is there the person it names cannot reach the dead-person sweep either. var numOrphaned = _libraryManager.DeleteOrphanedCredits(); if (numOrphaned > 0) { - _logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned); + _logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned); } - var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); - - var numComplete = 0; - - var numPeople = people.Count; + var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); + var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Person] + }).ToHashSet(); - IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); + var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds); - _logger.LogDebug("Will refresh {Amount} people", numPeople); + var numComplete = 0; + var count = names.Count; + var refreshed = 0; - foreach (var person in people) + foreach (var name in names) { cancellationToken.ThrowIfCancellationRequested(); try { - var item = _libraryManager.GetPerson(person); - if (item is null) - { - _logger.LogWarning("Failed to get person: {Name}", person); - continue; - } + var item = _libraryManager.GetOrCreatePerson(name); + var isNew = !existingPersonIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + if (isNew || neverRefreshed) { - ImageRefreshMode = MetadataRefreshMode.ValidationOnly, - MetadataRefreshMode = MetadataRefreshMode.ValidationOnly - }; - - await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { + // Don't clutter the log throw; } catch (Exception ex) { - _logger.LogError(ex, "Error validating IBN entry {Person}", person); + _logger.LogError(ex, "Error refreshing {PersonName}", name); } - // Update progress numComplete++; double percent = numComplete; - percent /= numPeople; + percent /= count; + percent *= 100; - subProgress.Report(100 * percent); + progress.Report(percent); } - var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Person], - IsDeadPerson = true, - IsLocked = false - }); + _logger.LogInformation( + "Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet", + refreshed, + count, + newNames.Count); - subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); + // A person somebody locked is theirs, not ours, however little the library still credits them. + var deadEntities = deadIds + .Select(_libraryManager.GetItemById) + .OfType<Person>() + .Where(item => !item.IsLocked) + .ToList(); - var i = 0; - foreach (var item in deadEntities.Chunk(500)) + foreach (var item in deadEntities) { - _libraryManager.DeleteItemsUnsafeFast(item, true); - subProgress.Report(100f / deadEntities.Count * (i++ * 100)); + _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); + } + + /// <summary> + /// Splits the person items into the ones a credit still calls for and the ones nothing does. + /// </summary> + /// <param name="creditNames">Every name credited on an item, from the people table.</param> + /// <param name="getPersonId">Maps a credit name to the id its person item has.</param> + /// <param name="existingPersonIds">The ids of the person items that exist.</param> + /// <returns>The credits needing an item, and the ids of the items nothing credits.</returns> + internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId( + IReadOnlyList<string> creditNames, + Func<string, Guid> getPersonId, + IReadOnlySet<Guid> existingPersonIds) + { + ArgumentNullException.ThrowIfNull(creditNames); + ArgumentNullException.ThrowIfNull(getPersonId); + ArgumentNullException.ThrowIfNull(existingPersonIds); + + var newNames = new List<string>(); + var liveIds = new HashSet<Guid>(); + + foreach (var name in creditNames) + { + var personId = getPersonId(name); + + // Distinct credit names can normalize onto one id; only the first of them needs an item. + if (liveIds.Add(personId) && !existingPersonIds.Contains(personId)) + { + newNames.Add(name); + } + } + + var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList(); - _logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned); + return (newNames, deadIds); } } diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 49ebc45f06..c5b1213096 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -112,5 +112,11 @@ "NameExtraInterview": "Інтэрв'ю", "NameExtraNumbered": "{0} {1}", "NameExtraScene": "Сцэна", - "NameExtraTrailer": "Трэйлер" + "NameExtraTrailer": "Трэйлер", + "NameExtraBehindTheScenes": "За кулісамі", + "NameExtraClip": "Кліп", + "NameExtraFeaturette": "Кароткаметражка", + "NameExtraSample": "Прыклад", + "NameExtraShort": "Кароткаметражка", + "NameExtraThemeSong": "Тэматычная песня" } diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json index aa7fe4eb24..5686807d9a 100644 --- a/Emby.Server.Implementations/Localization/Core/bs.json +++ b/Emby.Server.Implementations/Localization/Core/bs.json @@ -106,5 +106,17 @@ "TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay", "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.", "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", - "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana." + "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana.", + "NameExtraBehindTheScenes": "Iza kulisa", + "NameExtraClip": "Isječak", + "NameExtraDeletedScene": "Izbrišana scena", + "NameExtraFeaturette": "Kratki prilog", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Uzorak", + "NameExtraScene": "Scena", + "NameExtraShort": "Kratko", + "NameExtraThemeSong": "Tema", + "NameExtraThemeVideo": "Tematski video", + "NameExtraTrailer": "Najava" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 033002d2b2..cfc16d553c 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -1,6 +1,6 @@ { "AppDeviceValues": "Aplikace: {0}, Zařízení: {1}", - "Artists": "Umělci", + "Artists": "Interpreti", "AuthenticationSucceededWithUserName": "{0} úspěšně ověřen", "Books": "Knihy", "ChapterNameValue": "Kapitola {0}", 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/el.json b/Emby.Server.Implementations/Localization/Core/el.json index c0ad2c165a..ee03c471ea 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.", "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη", "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}", - "Original": "Πρωτότυπο" + "Original": "Πρωτότυπο", + "NameExtraBehindTheScenes": "Πίσω από τις Σκηνές", + "NameExtraDeletedScene": "Διεγραμμένη Σκηνή", + "NameExtraFeaturette": "Πρόσθετα βίντεο", + "NameExtraInterview": "Συνέντευξη", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Δείγμα", + "NameExtraScene": "Σκηνή", + "NameExtraShort": "Βίντεο μικρού μήκους", + "NameExtraThemeSong": "Θεματικό Τραγούδι", + "NameExtraThemeVideo": "Θεματικό Βίντεο", + "NameExtraTrailer": "τρέιλερ ταινίας", + "NameExtraUnknown": "Πρόσθετα", + "NameExtraClip": "Απόσπασμα" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index 36a248a1d1..9a453120dd 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -113,5 +113,12 @@ "NameExtraClip": "Klippi", "NameExtraDeletedScene": "Poistettu Kohtaus", "NameExtraFeaturette": "Lyhytelokuva", - "NameExtraInterview": "Haastattelu" + "NameExtraInterview": "Haastattelu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Näyte", + "NameExtraScene": "Kohtaus", + "NameExtraShort": "Lyhytfilmi", + "NameExtraThemeSong": "Tunnusmusiikki", + "NameExtraThemeVideo": "Tunnusvideo", + "NameExtraTrailer": "Traileri" } diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 1a1c89da35..acb78290f3 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -7,8 +7,8 @@ "AppDeviceValues": "App: {0}, Eind: {1}", "Books": "Bøkur", "ChapterNameValue": "Kapittul {0}", - "Favorites": "Yndis", - "Folders": "Mappur", + "Favorites": "Yndislisti", + "Folders": "Skjáttur", "Forced": "Kravt", "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", "HeaderFavoriteEpisodes": "Yndispartar", @@ -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", @@ -72,7 +72,7 @@ "UserStoppedPlayingItemWithValues": "{0} er liðugur 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 á {1}", + "UserOfflineFromDevice": "{0} breyt av frá {1}", "UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt", "TasksChannelsCategory": "Alnetsrásir", "TaskCleanActivityLog": "Reinsa virksemisskrá", @@ -83,7 +83,7 @@ "TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir", "TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir", "CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.", - "CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur", + "CleanupUserDataTask": "Reinsa brúkaradátur", "TaskRefreshPeople": "Dagfør persónsupplýsingar", "TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.", "TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.", @@ -96,15 +96,15 @@ "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", "NameExtraShort": "Stuttfilmur", "NameExtraThemeSong": "Eyðkennislag", - "NameExtraTrailer": "Forfilmur", + "NameExtraTrailer": "Brellbiti", "NameExtraInterview": "Samrøða", "NameExtraBehindTheScenes": "Aftanfyri leiktjøldini", "NameExtraClip": "Klipp", @@ -112,14 +112,14 @@ "NameExtraFeaturette": "Stuttur heimildarfilmur", "TaskAudioNormalization": "Ljóðjavnan", "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.", - "NameExtraSample": "Kut", + "NameExtraSample": "Sýnislutur", "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir", "TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.", - "TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað", + "TaskMoveTrickplayImages": "Flyt Trickplay-myndir", "TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.", "NameExtraThemeVideo": "Eyðkenniskykmynd", "NameExtraDeletedScene": "Úrtikin mynd", - "NameExtraScene": "Mynd (scena)", + "NameExtraScene": "Mynd", "NameExtraUnknown": "Eykatilfar", "Original": "Upprunalig(t/ur)" } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 1ee606cc64..30e11d15f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora", "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.", "Original": "Bunaidh", - "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}" + "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}", + "NameExtraBehindTheScenes": "Taobh thiar de na Radhairc", + "NameExtraClip": "Gearrthóg", + "NameExtraDeletedScene": "Radharc Scriosta", + "NameExtraFeaturette": "Mionghné", + "NameExtraInterview": "Agallamh", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sampla", + "NameExtraScene": "Radharc", + "NameExtraShort": "Gearr", + "NameExtraThemeSong": "Amhrán Téama", + "NameExtraThemeVideo": "Físeán Téama", + "NameExtraTrailer": "Leantóir" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 0ca7c6aa08..04877b18a7 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -112,8 +112,14 @@ "NameExtraBehindTheScenes": "מאחורי הקלעים", "NameExtraClip": "קליפ", "NameExtraDeletedScene": "סצנה שנמחקה", - "NameExtraFeaturette": "סרט קצר", + "NameExtraFeaturette": "סרט עלילה קצר", "NameExtraInterview": "ריאיון", "NameExtraSample": "דגימה", - "NameExtraScene": "סצנה" + "NameExtraScene": "סצנה", + "NameExtraThemeSong": "שיר נושא", + "NameExtraThemeVideo": "סרטון נושא", + "NameExtraTrailer": "קדימון", + "NameExtraUnknown": "נוסף", + "NameExtraNumbered": "{0} {1}", + "NameExtraShort": "סרט קצר" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 442c26b30b..c7e2b5f5e9 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.", "Original": "Original", - "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo" + "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo", + "NameExtraBehindTheScenes": "Iza kulisa", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Obrisana Scena", + "NameExtraFeaturette": "Promotivni video", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Uzorak", + "NameExtraScene": "Scena", + "NameExtraShort": "Kratki film", + "NameExtraThemeSong": "Glavna Pjesma", + "NameExtraThemeVideo": "Tema videa", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Dodatno" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 78b7ec744b..49757d66e7 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -2,7 +2,7 @@ "AppDeviceValues": "アプリ: {0}, デバイス: {1}", "Artists": "アーティスト", "AuthenticationSucceededWithUserName": "{0} 認証に成功しました", - "Books": "ブック", + "Books": "書籍", "ChapterNameValue": "チャプター {0}", "Collections": "コレクション", "FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました", diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json index 917f26a49c..1e64a130eb 100644 --- a/Emby.Server.Implementations/Localization/Core/lb.json +++ b/Emby.Server.Implementations/Localization/Core/lb.json @@ -83,7 +83,7 @@ "NewVersionIsAvailable": "Nei Versioun fir Jellyfin Server ass verfügbar.", "PluginInstalledWithName": "{0} installéiert", "TaskMoveTrickplayImagesDescription": "Verschëfft existent Trickplay-Dateien no de Bibliothéik-Astellungen.", - "AppDeviceValues": "App: {0}, Geräter: {1}", + "AppDeviceValues": "App: {0}, Apparater: {1}", "FailedLoginAttemptWithUserName": "Net Gelongen Umeldung {0}", "HeaderLiveTV": "LiveTV", "NotificationOptionServerRestartRequired": "Server Restart Erfuerderlech", @@ -108,5 +108,17 @@ "LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}", "Original": "Original", "CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten", - "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn." + "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.", + "NameExtraBehindTheScenes": "Hannert de Kulissen", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Geläschte Scène", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Beispill", + "NameExtraScene": "Scène", + "NameExtraShort": "Kuerzfilm", + "NameExtraThemeSong": "Theme-Lidd", + "NameExtraThemeVideo": "Theme-Video", + "NameExtraTrailer": "Bande-Annonce" } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index cc4899ed50..db4710ddca 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -96,18 +96,18 @@ "External": "Išorinis", "HearingImpaired": "Su klausos sutrikimais", "TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus", - "TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įgalintose bibliotekose, Trickplay peržiūras.", + "TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įjungtose bibliotekose, Trickplay peržiūras.", "TaskAudioNormalization": "Garso normalizavimas", "TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.", "TaskExtractMediaSegments": "Medijos segmentų nuskaitymas", - "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų žodžius", + "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus", "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.", "TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą", "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.", - "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius", + "TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus", "CleanupUserDataTask": "Naudotojo duomenų valymo užduotis", "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).", - "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}", + "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}", "NameExtraBehindTheScenes": "Užkulisiuose", "NameExtraClip": "Klipas", "NameExtraDeletedScene": "Ištrinta scena", diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 76fa9e3cf7..52f1eecbe4 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.", "Original": "Oriģināls", - "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}" + "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}", + "NameExtraBehindTheScenes": "Aiz kadra", + "NameExtraClip": "Klips", + "NameExtraDeletedScene": "Izdzēsta aina", + "NameExtraFeaturette": "Īsfilma", + "NameExtraInterview": "Intervija", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Paraugs", + "NameExtraScene": "Aina", + "NameExtraShort": "Īsfilma", + "NameExtraThemeSong": "Motīvu dziesma", + "NameExtraThemeVideo": "Tēmas video", + "NameExtraTrailer": "Treileris" } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 752b74ec1c..735bc2c793 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -106,5 +106,15 @@ "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.", "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.", "CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.", - "CleanupUserDataTask": "Oppgave for opprydding av brukerdata" + "CleanupUserDataTask": "Oppgave for opprydding av brukerdata", + "NameExtraBehindTheScenes": "Bak kulissene", + "NameExtraDeletedScene": "Slettet scene", + "NameExtraFeaturette": "Presentasjonsfilm", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Prøve", + "NameExtraScene": "Scene", + "NameExtraThemeSong": "Tema-låt", + "NameExtraThemeVideo": "Tema-video", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index e609ad52c8..39bbe8bb55 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -106,5 +106,6 @@ "TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।", "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।", "CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।", - "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ" + "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ", + "LyricDownloadFailureFromForItem": "{1} ਲਈ {0} ਤੋਂ ਬੋਲ ਡਾਊਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ।" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index 358c19881f..dccec8067d 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -111,5 +111,14 @@ "Original": "Original", "NameExtraBehindTheScenes": "În culise", "NameExtraClip": "Clip", - "NameExtraDeletedScene": "Scenă ștearsă" + "NameExtraDeletedScene": "Scenă ștearsă", + "NameExtraFeaturette": "Material bonus", + "NameExtraInterview": "Interviu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Monstră", + "NameExtraScene": "Scenă", + "NameExtraShort": "Scurt", + "NameExtraThemeSong": "Audio de Fundal", + "NameExtraThemeVideo": "Video de Fundal", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 9573eeefe4..a3ae6139ae 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -91,7 +91,7 @@ "Default": "Predvolené", "TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.", "TaskOptimizeDatabase": "Optimalizovať databázu", - "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov prehrávania. Táto úloha môže trvať dlhšiu dobu.", + "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov. Táto úloha môže trvať dlhší čas.", "TaskKeyframeExtractor": "Extraktor kľúčových snímkov", "External": "Externé", "HearingImpaired": "Sluchovo postihnutí", diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index a1b5b714af..6ea625d66c 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Čiščenje uporabniških podatkov", "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.", "LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "V zakulisju", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Izbrisan prizor", + "NameExtraFeaturette": "Kratek dokumentarec o izdelavi filma", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Vzorec", + "NameExtraScene": "Prizor", + "NameExtraShort": "Kratki film", + "NameExtraThemeSong": "Tematska Pesem", + "NameExtraThemeVideo": "Tematski Video", + "NameExtraTrailer": "Napovednik" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 716e3ae55d..77e526db74 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -22,20 +22,20 @@ "NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว", "NameSeasonUnknown": "ไม่ทราบซีซัน", "NameSeasonNumber": "ซีซัน {0}", - "NameInstallFailed": "การติดตั้ง {0} ล้มเหลว", + "NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ", "MusicVideos": "มิวสิควิดีโอ", - "Music": "ดนตรี", + "Music": "เพลง", "Movies": "ภาพยนตร์", - "MixedContent": "เนื้อหาผสม", - "Latest": "ล่าสุด", - "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}", - "LabelIpAddressValue": "ที่อยู่ IP: {0}", - "Inherit": "สืบทอด", - "HomeVideos": "โฮมวิดีโอ", - "HeaderNextUp": "ถัดไป", - "HeaderLiveTV": "ทีวีสด", - "HeaderFavoriteShows": "รายการที่ชื่นชอบ", - "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ", + "MixedContent": "เนื้อหาหลากหลายประเภท", + "Latest": "มาใหม่ล่าสุด", + "LabelRunningTimeValue": "ความยาว: {0}", + "LabelIpAddressValue": "หมายเลข IP: {0}", + "Inherit": "ใช้ค่าเริ่มต้น", + "HomeVideos": "วิดีโอส่วนตัว", + "HeaderNextUp": "รายการถัดไป", + "HeaderLiveTV": "ทีวีถ่ายทอดสด", + "HeaderFavoriteShows": "รายการที่ชอบ", + "HeaderFavoriteEpisodes": "ตอนที่ชอบ", "HeaderContinueWatching": "ดูต่อ", "Genres": "ประเภท", "Folders": "โฟลเดอร์", @@ -107,6 +107,19 @@ "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay", "CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้", "CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน", - "LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}", - "Original": "ต้นฉบับ" + "LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ", + "Original": "ต้นฉบับ", + "NameExtraBehindTheScenes": "เบื้องหลังการถ่ายทำ", + "NameExtraClip": "คลิปวิดีโอ", + "NameExtraDeletedScene": "ฉากที่ถูกตัดออก", + "NameExtraFeaturette": "คลิปสั้นพิเศษ", + "NameExtraInterview": "บทสัมภาษณ์", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "ตัวอย่าง", + "NameExtraScene": "ฉาก", + "NameExtraShort": "ภาพยนตร์สั้น", + "NameExtraThemeSong": "เพลงประกอบ", + "NameExtraThemeVideo": "วิดีโอธีม", + "NameExtraTrailer": "ตัวอย่างภาพยนตร์", + "NameExtraUnknown": "เนื้อหาพิเศษ" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 67f4ecc4d5..87f49ae80d 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -81,8 +81,8 @@ "TaskCleanLogs": "Günlük Dizinini Temizle", "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.", "TaskRefreshLibrary": "Medya Kütüphanesini Tara", - "TaskRefreshChapterImagesDescription": "Bölümlere ayrılmış videolar için küçük resimler oluştur.", - "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar", + "TaskRefreshChapterImagesDescription": "Video bölümleri için küçük görseller oluştur.", + "TaskRefreshChapterImages": "Bölüm görsellerini çıkar", "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.", "TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle", "TaskCleanActivityLogDescription": "Yapılandırılan tarihten daha eski olan etkinlik günlüğü girişlerini siler.", diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index b3f24a31e3..a8ec31aed4 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -98,5 +98,28 @@ "TaskDownloadMissingLyrics": "غائب بول ڈاؤن لوڈ کریں", "TaskDownloadMissingLyricsDescription": "گانے کے غائب بول ڈاؤن لوڈ کریں", "TaskAudioNormalization": "آڈیو نارملائزیشن", - "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔" + "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔", + "LyricDownloadFailureFromForItem": "{1} کے لیے {0} سے دھن ڈاؤن لوڈ کرنے میں ناکامی", + "Original": "اصل", + "TaskRefreshTrickplayImages": "Trickplay تصاویر بنائیں", + "TaskRefreshTrickplayImagesDescription": "فعال لائبریریوں میں ویڈیوز کے لیے Trickplay پیش منظر بناتا ہے۔", + "TaskExtractMediaSegments": "میڈیا سیگمنٹ اسکین", + "TaskExtractMediaSegmentsDescription": "MediaSegment فعال پلگ انز سے میڈیا سیگمنٹس اخذ یا حاصل کرتا ہے۔", + "TaskMoveTrickplayImages": "Trickplay تصاویر کا مقام منتقل کریں", + "TaskMoveTrickplayImagesDescription": "لائبریری کی ترتیبات کے مطابق موجودہ Trickplay فائلیں منتقل کرتا ہے۔", + "CleanupUserDataTask": "صارف ڈیٹا صفائی کا ٹاسک", + "CleanupUserDataTaskDescription": "کم از کم 90 دن سے موجود نہ ہونے والے میڈیا سے تمام صارف ڈیٹا (دیکھنے کی حالت، پسندیدہ حیثیت وغیرہ) صاف کرتا ہے۔", + "NameExtraBehindTheScenes": "پسِ پردہ", + "NameExtraClip": "کلپ", + "NameExtraDeletedScene": "حذف شدہ منظر", + "NameExtraFeaturette": "فیچریٹ", + "NameExtraInterview": "انٹرویو", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "نمونہ", + "NameExtraScene": "منظر", + "NameExtraShort": "مختصر", + "NameExtraThemeSong": "تھیم سونگ", + "NameExtraThemeVideo": "تھیم ویڈیو", + "NameExtraTrailer": "ٹریلر", + "NameExtraUnknown": "اضافی" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 9590d9c1c9..7c61ce6f17 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -22,7 +22,7 @@ "MixedContent": "混合内容", "Movies": "电影", "Music": "音乐", - "MusicVideos": "MV", + "MusicVideos": "音乐视频", "NameInstallFailed": "{0} 安装失败", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季", diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 0331ec39e5..3f89237ab2 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -139,7 +139,8 @@ namespace Emby.Server.Implementations.Localization var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); - var dict = new Dictionary<string, ParentalRatingScore?>(); + // Rating strings are compared case insensitively, providers are not consistent about casing (e.g. "VM18" vs "vm18") + var dict = new Dictionary<string, ParentalRatingScore?>(StringComparer.OrdinalIgnoreCase); if (ratingSystem.Ratings is not null) { foreach (var ratingEntry in ratingSystem.Ratings) @@ -374,12 +375,37 @@ namespace Emby.Server.Implementations.Localization { ArgumentException.ThrowIfNullOrEmpty(rating); + // Handle unrated content. This has to happen before the split below, + // because some of the unrated values contain a '/' themselves (e.g. "n/a"). + if (IsUnrated(rating)) + { + return null; + } + + // Several rating systems contain a '/' inside a single rating (e.g. "M/12" in PT, + // "U/A 13+" in IN, "7/i/fig" in ES), so the value as a whole always wins over the split below. + var wholeValueScore = GetSingleRatingScore(rating, countryCode); + if (wholeValueScore is not null) + { + return wholeValueScore; + } + // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år"). // Try each one in order and use the first that resolves. var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (ratingValues.Length == 1) + { + return null; + } foreach (var ratingValue in ratingValues) { + // A single entry of such a list may be unrated while a later one still resolves + if (IsUnrated(ratingValue)) + { + continue; + } + var score = GetSingleRatingScore(ratingValue, countryCode); if (score is not null) { @@ -391,16 +417,18 @@ namespace Emby.Server.Implementations.Localization } /// <summary> + /// Checks whether a rating value marks the content as unrated. + /// </summary> + /// <param name="rating">Rating value to check.</param> + /// <returns>Returns true if the value is an unrated marker.</returns> + private static bool IsUnrated(ReadOnlySpan<char> rating) + => _unratedValues.Contains(rating.Trim(), StringComparison.OrdinalIgnoreCase); + + /// <summary> /// Resolves a single rating value to a score. /// </summary> private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode) { - // Handle unrated content - if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - return null; - } - // Convert ints directly // This may override some of the locale specific age ratings (but those always map to the same age) if (TryParseRatingAsScore(rating, out var ratingAge)) @@ -512,6 +540,12 @@ namespace Emby.Server.Implementations.Localization return true; } + // Explicitly unrated content (e.g. "IT-NR") is unrated by definition, not a lookup failure + if (IsUnrated(ratingPart)) + { + return true; + } + _logger.LogWarning( "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", rating, diff --git a/Emby.Server.Implementations/Localization/Ratings/it.json b/Emby.Server.Implementations/Localization/Ratings/it.json index f2889bf82c..c1d716a46c 100644 --- a/Emby.Server.Implementations/Localization/Ratings/it.json +++ b/Emby.Server.Implementations/Localization/Ratings/it.json @@ -3,28 +3,42 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["T"], + "ratingStrings": ["T", "PT", "Per tutti"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["6+"], + "ratingStrings": ["6+", "VM6", "VM 6", "VM-6", "Vietato ai minori di 6 anni"], "ratingScore": { "score": 6, "subScore": null } }, { - "ratingStrings": ["14+"], + "ratingStrings": ["10+", "VM10", "VM 10", "VM-10", "Vietato ai minori di 10 anni"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["12+", "VM12", "VM 12", "VM-12", "Vietato ai minori di 12 anni"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["14+", "VM14", "VM 14", "VM-14", "Vietato ai minori di 14 anni"], "ratingScore": { "score": 14, "subScore": null } }, { - "ratingStrings": ["18+"], + "ratingStrings": ["18+", "VM18", "VM 18", "VM-18", "Vietato ai minori di 18 anni"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/no.json b/Emby.Server.Implementations/Localization/Ratings/no.json index a5e9523163..40c66e3956 100644 --- a/Emby.Server.Implementations/Localization/Ratings/no.json +++ b/Emby.Server.Implementations/Localization/Ratings/no.json @@ -10,49 +10,49 @@ } }, { - "ratingStrings": ["6"], + "ratingStrings": ["6", "6 år"], "ratingScore": { "score": 6, "subScore": null } }, { - "ratingStrings": ["7"], + "ratingStrings": ["7", "7 år"], "ratingScore": { "score": 7, "subScore": null } }, { - "ratingStrings": ["9"], + "ratingStrings": ["9", "9 år"], "ratingScore": { "score": 9, "subScore": null } }, { - "ratingStrings": ["11"], + "ratingStrings": ["11", "11 år"], "ratingScore": { "score": 11, "subScore": null } }, { - "ratingStrings": ["12"], + "ratingStrings": ["12", "12 år"], "ratingScore": { "score": 12, "subScore": null } }, { - "ratingStrings": ["15"], + "ratingStrings": ["15", "15 år"], "ratingScore": { "score": 15, "subScore": null } }, { - "ratingStrings": ["18"], + "ratingStrings": ["18", "18 år"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 308faed8cc..8208c85222 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -371,7 +371,7 @@ namespace Emby.Server.Implementations.Playlists if (extension.Equals(".wpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new WplPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new WplPlaylistEntry() { @@ -404,7 +404,7 @@ namespace Emby.Server.Implementations.Playlists else if (extension.Equals(".zpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new ZplPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new ZplPlaylistEntry() { @@ -440,7 +440,7 @@ namespace Emby.Server.Implementations.Playlists { IsExtended = true }; - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new M3uPlaylistEntry() { @@ -472,7 +472,7 @@ namespace Emby.Server.Implementations.Playlists IsExtended = true }; - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new M3uPlaylistEntry() { @@ -500,7 +500,7 @@ namespace Emby.Server.Implementations.Playlists else if (extension.Equals(".pls", StringComparison.OrdinalIgnoreCase)) { var playlist = new PlsPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new PlsPlaylistEntry() { diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 8d29d6a512..f7ad9b9498 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -395,29 +395,11 @@ namespace Emby.Server.Implementations.Plugins var url = new Uri(packageInfo.ImageUrl); imagePath = Path.Join(path, url.Segments[^1]); - var fileStream = AsyncFile.OpenWrite(imagePath); - Stream? downloadStream = null; - try + // The catalog is refreshed on every dashboard visit and rewrites the manifest of + // every installed plugin, so only fetch an image that is actually missing. + if (!ImageExists(imagePath)) { - downloadStream = await HttpClientFactory - .CreateClient(NamedClient.Default) - .GetStreamAsync(url) - .ConfigureAwait(false); - - await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); - } - catch (HttpRequestException ex) - { - _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); - imagePath = string.Empty; - } - finally - { - await fileStream.DisposeAsync().ConfigureAwait(false); - if (downloadStream is not null) - { - await downloadStream.DisposeAsync().ConfigureAwait(false); - } + imagePath = await DownloadImage(url, imagePath).ConfigureAwait(false); } } @@ -456,6 +438,67 @@ namespace Emby.Server.Implementations.Plugins } } + private static bool ImageExists(string imagePath) + { + var image = new FileInfo(imagePath); + + // A previous download may have been interrupted, leaving an empty file behind. + return image.Exists && image.Length > 0; + } + + private async Task<string> DownloadImage(Uri url, string imagePath) + { + // Download to a temporary file and move it into place, so that neither a failed download + // nor a concurrent one can be observed as a partially written image. + var tempPath = imagePath + "." + Path.GetRandomFileName(); + + try + { + var fileStream = AsyncFile.Create(tempPath); + Stream? downloadStream = null; + try + { + downloadStream = await HttpClientFactory + .CreateClient(NamedClient.Default) + .GetStreamAsync(url) + .ConfigureAwait(false); + + await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); + } + finally + { + await fileStream.DisposeAsync().ConfigureAwait(false); + if (downloadStream is not null) + { + await downloadStream.DisposeAsync().ConfigureAwait(false); + } + } + + File.Move(tempPath, imagePath, true); + + return imagePath; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException) + { + _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); + TryDeleteFile(tempPath); + + return string.Empty; + } + } + + private void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Unable to delete {Path}.", path); + } + } + /// <summary> /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path. /// If no file is found, no reconciliation occurs. diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 8d133dc074..687947616f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; /// <summary> -/// Optimizes Jellyfin's database by issuing a VACUUM command. +/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands. /// </summary> public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask { @@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask return; } - _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); + _logger.LogInformation("Vacuuming and analyzing jellyfin.db..."); try { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 42835d7ad0..092a621bfc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Emby.Server.Implementations.Library.Validators; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; @@ -29,6 +30,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; private readonly IFileSystem _fileSystem; private readonly ILogger<PeopleValidationTask> _logger; + private readonly ILogger<PeopleValidator> _validatorLogger; private readonly IItemTypeLookup _itemTypeLookup; /// <summary> @@ -39,6 +41,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param> + /// <param name="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> interface.</param> /// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param> public PeopleValidationTask( ILibraryManager libraryManager, @@ -46,6 +49,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask IDbContextFactory<JellyfinDbContext> dbContextFactory, IFileSystem fileSystem, ILogger<PeopleValidationTask> logger, + ILogger<PeopleValidator> validatorLogger, IItemTypeLookup itemTypeLookup) { _libraryManager = libraryManager; @@ -53,6 +57,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask _dbContextFactory = dbContextFactory; _fileSystem = fileSystem; _logger = logger; + _validatorLogger = validatorLogger; _itemTypeLookup = itemTypeLookup; } @@ -165,7 +170,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are // cleaned up above, so dead people are removed in a single pass instead of requiring a second run. IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33)); - await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false); + await new PeopleValidator(_libraryManager, _validatorLogger) + .Run(validateProgress, cancellationToken) + .ConfigureAwait(false); // Phase 3: Refresh images for people missing them (66-100%) IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66)); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index 31153af20f..dd3da2214a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask { _logger.LogError(ex, "Error updating {Name}", package.Name); } + catch (TimeoutException ex) + { + // One slow download must not abort the updates for the remaining plugins. + _logger.LogError(ex, "Error downloading {Name}", package.Name); + } catch (InvalidDataException ex) { _logger.LogError(ex, "Error updating {Name}", package.Name); diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 94215bed79..08e2578867 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1449,6 +1449,8 @@ namespace Emby.Server.Implementations.Session if (item is IItemByName byName) { + // A by-name item tags containers as well as leaves: a music genre tags its artists, + // and a by-name artist row is not a folder, so IsFolder does not exclude it here. return byName.GetTaggedItems(new InternalItemsQuery(user) { IsFolder = false, @@ -1463,7 +1465,7 @@ namespace Emby.Server.Implementations.Session }, IsVirtualItem = false, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } - }); + }).Where(i => i is not IItemByName); } if (item.IsFolder) diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index e81edc82c6..2100c23c45 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -171,7 +171,7 @@ namespace Emby.Server.Implementations.Session { await SendForceKeepAlive(webSocket).ConfigureAwait(false); } - catch (WebSocketException exception) + catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or OperationCanceledException) { _logger.LogWarning(exception, "Cannot send ForceKeepAlive message to WebSocket {0}.", webSocket); } @@ -232,7 +232,7 @@ namespace Emby.Server.Implementations.Session { await SendForceKeepAlive(webSocket).ConfigureAwait(false); } - catch (WebSocketException exception) + catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or OperationCanceledException) { _logger.LogInformation(exception, "Error sending ForceKeepAlive message to WebSocket."); lost.Add(webSocket); diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index 38a0018a70..6fbe46ffd6 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -11,6 +11,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; using MediaBrowser.Controller.SyncPlay.Queue; using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; @@ -27,6 +28,11 @@ namespace Emby.Server.Implementations.SyncPlay public class Group : IGroupStateContext { /// <summary> + /// The default value of <see cref="GroupWaitTimeout"/>, in milliseconds. + /// </summary> + internal const long DefaultGroupWaitTimeout = 30000; + + /// <summary> /// The logger. /// </summary> private readonly ILogger<Group> _logger; @@ -54,8 +60,12 @@ namespace Emby.Server.Implementations.SyncPlay /// <summary> /// The participants, or members of the group. /// </summary> - private readonly Dictionary<string, GroupMember> _participants = - new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, GroupMember> _participants = new(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// The sessions of the participants, which only carry identifiers. + /// </summary> + private readonly Dictionary<string, SessionInfo> _participantSessions = new(StringComparer.OrdinalIgnoreCase); /// <summary> /// The internal group state. @@ -91,6 +101,18 @@ namespace Emby.Server.Implementations.SyncPlay public long DefaultPing { get; } = 500; /// <summary> + /// Gets the maximum ping, in milliseconds, accepted from a session. + /// </summary> + /// <remarks> + /// Pings are reported by clients and are scaled into the delays used to schedule playback, + /// so an unbounded value lets a single session push the whole group's resume point + /// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a + /// usable measurement for synchronisation. + /// </remarks> + /// <value>The maximum ping.</value> + public long MaxPing { get; } = 10000; + + /// <summary> /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. /// </summary> /// <value>The maximum time offset error.</value> @@ -103,6 +125,19 @@ namespace Emby.Server.Implementations.SyncPlay public long MaxPlaybackOffset { get; } = 500; /// <summary> + /// Gets the maximum time, in milliseconds, the group waits for its members to report ready. + /// </summary> + /// <value>The group-wait timeout.</value> + internal long GroupWaitTimeout { get; init; } = DefaultGroupWaitTimeout; + + /// <summary> + /// Gets the <see cref="Environment.TickCount64"/> value at which the group gives up waiting + /// for its members, or <c>null</c> when it is not waiting for anyone. + /// </summary> + /// <value>The group-wait deadline.</value> + internal long? GroupWaitDeadline { get; private set; } + + /// <summary> /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> @@ -151,6 +186,8 @@ namespace Emby.Server.Implementations.SyncPlay Ping = DefaultPing, IsBuffering = false }); + + _participantSessions[session.Id] = session; } /// <summary> @@ -160,6 +197,8 @@ namespace Emby.Server.Implementations.SyncPlay private void RemoveSession(SessionInfo session) { _participants.Remove(session.Id); + _participantSessions.Remove(session.Id); + UpdateGroupWaitDeadline(false); } /// <summary> @@ -377,13 +416,20 @@ namespace Emby.Server.Implementations.SyncPlay { value.IgnoreGroupWait = ignoreGroupWait; } + + UpdateGroupWaitDeadline(false); } /// <inheritdoc /> public void SetState(IGroupState state) { _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type); - this._state = state; + _state = state; + + if (state.Type != GroupStateType.Waiting) + { + GroupWaitDeadline = null; + } } /// <inheritdoc /> @@ -438,7 +484,7 @@ namespace Emby.Server.Implementations.SyncPlay { if (_participants.TryGetValue(session.Id, out GroupMember value)) { - value.Ping = ping; + value.Ping = Math.Clamp(ping, 0, MaxPing); } } @@ -451,7 +497,9 @@ namespace Emby.Server.Implementations.SyncPlay max = Math.Max(max, session.Ping); } - return max; + // A group with no participants has no ping to report. Returning long.MinValue would + // overflow the callers that scale this value into ticks, so fall back to the default. + return max == long.MinValue ? DefaultPing : max; } /// <inheritdoc /> @@ -461,6 +509,8 @@ namespace Emby.Server.Implementations.SyncPlay { value.IsBuffering = isBuffering; } + + UpdateGroupWaitDeadline(false); } /// <inheritdoc /> @@ -470,6 +520,9 @@ namespace Emby.Server.Implementations.SyncPlay { session.IsBuffering = isBuffering; } + + // Resetting the status of every session starts a new waiting period. + UpdateGroupWaitDeadline(isBuffering); } /// <inheritdoc /> @@ -676,5 +729,85 @@ namespace Emby.Server.Implementations.SyncPlay PlayQueue.ShuffleMode, PlayQueue.RepeatMode); } + + /// <summary> + /// Stops waiting for the members that have not reported ready and lets the rest of the + /// group carry on. Does nothing until <see cref="GroupWaitDeadline"/> has passed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + internal void HandleGroupWaitTimeout(CancellationToken cancellationToken) + { + var deadline = GroupWaitDeadline; + if (deadline is null || deadline > Environment.TickCount64) + { + return; + } + + GroupWaitDeadline = null; + + if (_state is not WaitingGroupState waitingState) + { + return; + } + + var blockingSessions = _participantSessions + .Values + .Where(participant => _participants.TryGetValue(participant.Id, out var member) + && member.IsBuffering + && !member.IgnoreGroupWait) + .ToList(); + + if (blockingSessions.Count == 0) + { + return; + } + + // The recovery below is broadcast to the whole group, so it does not matter which of + // the sessions that kept the group waiting is the one acting on the group's behalf. + var session = blockingSessions[0]; + + _logger.LogWarning( + "Group {GroupId} waited {Waited} ms for session(s) {SessionIds} to report ready, giving up.", + GroupId.ToString(), + GroupWaitTimeout + Environment.TickCount64 - deadline.Value, + string.Join(", ", blockingSessions.Select(participant => participant.Id))); + + if (waitingState.ResumePlaying) + { + // An unpause request in the waiting state means "start now, ignoring the sessions + // that are not ready". + var unpauseRequest = new UnpauseGroupRequest(); + waitingState.HandleRequest(unpauseRequest, this, GroupStateType.Waiting, session, cancellationToken); + return; + } + + // The members have been paused for the whole waiting period, so the playback position + // stays where the wait started. + SetAllBuffering(false); + SetState(new PausedGroupState(_loggerFactory)); + + var command = NewSyncPlayCommand(SendCommandType.Pause); + SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + var stateUpdate = new GroupStateUpdate(GroupStateType.Paused, PlaybackRequestType.Pause); + var update = new SyncPlayStateUpdate(GroupId, stateUpdate); + SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + private void UpdateGroupWaitDeadline(bool startNewWaitingPeriod) + { + if (_state.Type != GroupStateType.Waiting || !IsBuffering()) + { + GroupWaitDeadline = null; + return; + } + + // A running deadline covers the waiting period as a whole, so the sessions that keep + // reporting buffering while they load must not push it back. + if (GroupWaitDeadline is null || startNewWaitingPeriod) + { + GroupWaitDeadline = Environment.TickCount64 + GroupWaitTimeout; + } + } } } diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index b45d754554..88dfb070b8 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -19,6 +19,11 @@ namespace Emby.Server.Implementations.SyncPlay public class SyncPlayManager : ISyncPlayManager, IDisposable { /// <summary> + /// How often, in milliseconds, the groups are checked for a spent wait deadline. + /// </summary> + private const int GroupWaitSweepInterval = 1000; + + /// <summary> /// The logger. /// </summary> private readonly ILogger<SyncPlayManager> _logger; @@ -69,6 +74,11 @@ namespace Emby.Server.Implementations.SyncPlay /// </remarks> private readonly Lock _groupsLock = new(); + /// <summary> + /// The timer that watches the groups' wait deadlines, running only while there are groups. + /// </summary> + private readonly Timer _groupWaitTimer; + private bool _disposed = false; /// <summary> @@ -90,8 +100,15 @@ namespace Emby.Server.Implementations.SyncPlay _libraryManager = libraryManager; _logger = loggerFactory.CreateLogger<SyncPlayManager>(); _sessionManager.SessionEnded += OnSessionEnded; + _groupWaitTimer = new Timer(_ => OnGroupWaitTimerTick(), null, Timeout.Infinite, Timeout.Infinite); } + /// <summary> + /// Gets the maximum time, in milliseconds, a group waits for its members to report ready. + /// </summary> + /// <value>The group-wait timeout.</value> + internal long GroupWaitTimeout { get; init; } = Group.DefaultGroupWaitTimeout; + /// <inheritdoc /> public void Dispose() { @@ -122,8 +139,12 @@ namespace Emby.Server.Implementations.SyncPlay LeaveGroup(session, leaveGroupRequest, cancellationToken); } - var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager); + var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager) + { + GroupWaitTimeout = GroupWaitTimeout + }; _groups[group.GroupId] = group; + UpdateGroupWaitTimer(); if (!_sessionToGroupMap.TryAdd(session.Id, group)) { @@ -181,8 +202,8 @@ namespace Emby.Server.Implementations.SyncPlay { if (existingGroup.GroupId.Equals(request.GroupId)) { - // Restore session. - UpdateSessionsCounter(session.UserId, 1); + // Restore session. The session is already in the group and has already + // been counted, so the counter must not be incremented a second time. group.SessionJoin(session, request, cancellationToken); return; } @@ -242,6 +263,7 @@ namespace Emby.Server.Implementations.SyncPlay { _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); _groups.Remove(group.GroupId, out _); + UpdateGroupWaitTimer(); } } } @@ -332,8 +354,11 @@ namespace Emby.Server.Implementations.SyncPlay // Group lock required as Group is not thread-safe. lock (group) { - // Make sure that session still belongs to this group. - if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId)) + // Make sure that session still belongs to this group. The lookup can fail + // outright when the session left while this request was waiting on the group + // lock, which is exactly the case this re-check exists to catch. + if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) + || !checkGroup.GroupId.Equals(group.GroupId)) { // Drop request. return; @@ -381,7 +406,50 @@ namespace Emby.Server.Implementations.SyncPlay } _sessionManager.SessionEnded -= OnSessionEnded; - _disposed = true; + + lock (_groupsLock) + { + _disposed = true; + _groupWaitTimer.Dispose(); + } + } + + private void UpdateGroupWaitTimer() + { + if (_disposed) + { + return; + } + + var interval = _groups.IsEmpty ? Timeout.Infinite : GroupWaitSweepInterval; + _groupWaitTimer.Change(interval, interval); + } + + private void OnGroupWaitTimerTick() + { + try + { + lock (_groupsLock) + { + if (_disposed) + { + return; + } + + foreach (var (_, group) in _groups) + { + // Group lock required as Group is not thread-safe. + lock (group) + { + group.HandleGroupWaitTimeout(CancellationToken.None); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while recovering SyncPlay groups from a timed out wait."); + } } private void OnSessionEnded(object sender, SessionEventArgs e) @@ -400,7 +468,7 @@ namespace Emby.Server.Implementations.SyncPlay // Update sessions counter. var newSessionsCounter = _activeUsers.AddOrUpdate( userId, - 1, + toAdd, (_, sessionsCounter) => sessionsCounter + toAdd); // Should never happen. diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 174234b96b..cccdb3e6aa 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -11,7 +11,6 @@ using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; @@ -34,6 +33,9 @@ namespace Emby.Server.Implementations.Updates public class InstallationManager : IInstallationManager { private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']); + // Budget for the whole package download. The response headers are already bounded by the + // HttpClient timeout; this covers reading the package body, which can be large and slow. + private static readonly TimeSpan PackageDownloadTimeout = TimeSpan.FromMinutes(10); /// <summary> /// The logger. @@ -82,8 +84,8 @@ namespace Emby.Server.Implementations.Updates IServerConfigurationManager config, IPluginManager pluginManager) { - _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); - _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>(); + _currentInstallations = []; + _completedInstallationsInternal = []; _logger = logger; _applicationHost = appHost; @@ -341,8 +343,9 @@ namespace Emby.Server.Implementations.Updates _applicationHost.NotifyPendingRestart(); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (linkedToken.IsCancellationRequested) { + // Only an actually cancelled token is a cancellation. lock (_currentInstallationsLock) { _currentInstallations.Remove(tuple); @@ -356,7 +359,7 @@ namespace Emby.Server.Implementations.Updates } catch (Exception ex) { - _logger.LogError(ex, "Package installation failed"); + _logger.LogError(ex, "Package installation failed: {Name} {Version}", package.Name, package.Version); lock (_currentInstallationsLock) { @@ -546,12 +549,36 @@ namespace Emby.Server.Implementations.Updates throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); } - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) + // ResponseHeadersRead keeps the body out of the HttpClient timeout, which otherwise covers + // the whole download; the package gets the longer budget below instead. + using var downloadTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + downloadTokenSource.CancelAfter(PackageDownloadTimeout); + var downloadToken = downloadTokenSource.Token; + + var buffer = new MemoryStream(); + await using (buffer.ConfigureAwait(false)) { + try + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(new Uri(package.SourceUrl), HttpCompletionOption.ResponseHeadersRead, downloadToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + // The package is read twice, for the checksum and for the extraction, so it has + // to be buffered: the response stream is not seekable. + await response.Content.CopyToAsync(buffer, downloadToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // Either our budget above or the HttpClient timeout ran out. + throw new TimeoutException( + $"Downloading the package {package.Name} {package.Version} from {package.SourceUrl} timed out.", + ex); + } + + buffer.Position = 0; + Stream stream = buffer; + // CA5351: Do Not Use Broken Cryptographic Algorithms #pragma warning disable CA5351 cancellationToken.ThrowIfCancellationRequested(); diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs index 7efb5b1698..76874d10de 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SyncPlay; @@ -34,6 +35,13 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement) { var userId = context.User.GetUserId(); + if (userId.IsEmpty()) + { + // Unauthenticated requests and API keys carry no user, so there is nothing to + // check: leave the requirement unsatisfied and let the request be challenged. + return Task.CompletedTask; + } + var user = _userManager.GetUserById(userId); if (user is null) { diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index b458bd90f3..b9ca45cc1c 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -178,16 +178,35 @@ public class FilterController : BaseJellyfinApiController IsSeries = isSeries }; + var tagQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions + { + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) { var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; genreQuery.AncestorIds = ancestorIds; streamLanguageQuery.AncestorIds = ancestorIds; + tagQuery.AncestorIds = ancestorIds; } else { genreQuery.Parent = parentItem; streamLanguageQuery.Parent = parentItem; + tagQuery.Parent = parentItem; } if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season)) @@ -218,6 +237,8 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } + filters.Tags = _libraryManager.GetTagNames(tagQuery); + if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season) diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 5c596c21b9..7f63e410a5 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -16,6 +16,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; @@ -34,6 +35,7 @@ public class LibraryStructureController : BaseJellyfinApiController private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly ILibraryMonitor _libraryMonitor; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. @@ -41,14 +43,17 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> + /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param> public LibraryStructureController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor) + ILibraryMonitor libraryMonitor, + IDirectoryService directoryService) { _appPaths = serverConfigurationManager.ApplicationPaths; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; + _directoryService = directoryService; } /// <summary> @@ -178,11 +183,11 @@ public class LibraryStructureController : BaseJellyfinApiController var tempPath = Path.Combine( rootFolderPath, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); - Directory.Move(currentPath, tempPath); + _directoryService.Move(currentPath, tempPath); currentPath = tempPath; } - Directory.Move(currentPath, newPath); + _directoryService.Move(currentPath, newPath); } finally { @@ -208,7 +213,7 @@ public class LibraryStructureController : BaseJellyfinApiController { _libraryManager.ClearIgnoreRuleCache(); // We don't know if this one can be validated individually, trigger a new validation - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } _libraryManager.ClearIgnoreRuleCache(); @@ -255,7 +260,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } else { @@ -322,7 +327,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } else { diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 1f8f963f70..10bcf4e717 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Api; +using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Updates; @@ -23,16 +25,22 @@ public class PackageController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IPluginManager _pluginManager; /// <summary> /// Initializes a new instance of the <see cref="PackageController"/> class. /// </summary> /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) + /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> + public PackageController( + IInstallationManager installationManager, + IServerConfigurationManager serverConfigurationManager, + IPluginManager pluginManager) { _installationManager = installationManager; _serverConfigurationManager = serverConfigurationManager; + _pluginManager = pluginManager; } /// <summary> @@ -48,6 +56,13 @@ public class PackageController : BaseJellyfinApiController [FromRoute, Required] string name, [FromQuery] Guid? assemblyGuid) { + // Plugins bundled with the server are not published to any repository, so querying + // the configured repositories for them can only ever fail, and does so slowly. + if (IsBundledPlugin(name, assemblyGuid)) + { + return NotFound(); + } + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); var result = _installationManager.FilterPackages( packages, @@ -96,6 +111,11 @@ public class PackageController : BaseJellyfinApiController [FromQuery] string? version, [FromQuery] string? repositoryUrl) { + if (IsBundledPlugin(name, assemblyGuid)) + { + return NotFound(); + } + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); if (!string.IsNullOrEmpty(repositoryUrl)) { @@ -161,4 +181,13 @@ public class PackageController : BaseJellyfinApiController _serverConfigurationManager.SaveConfiguration(); return NoContent(); } + + private bool IsBundledPlugin(string name, Guid? assemblyGuid) + { + var plugin = assemblyGuid is Guid id && !id.IsEmpty() + ? _pluginManager.GetPlugin(id) + : _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + return plugin?.Instance?.CanUninstall == false; + } } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index e5df873f5b..c4851091c1 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -557,7 +557,7 @@ public class SubtitleController : BaseJellyfinApiController if (!string.IsNullOrEmpty(fallbackFontPath)) { var fontFile = _fileSystem.GetFiles(fallbackFontPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); var fileSize = fontFile?.Length; if (fontFile is not null && fileSize is not null && fileSize > 0) diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 8b359c48af..a934e5dbf8 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -90,7 +90,7 @@ public class UserViewsController : BaseJellyfinApiController var dtoOptions = new DtoOptions(); dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; - var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user)); + var dtos = _dtoService.GetBaseItemDtos(folders, dtoOptions, user, skipVisibilityCheck: true); return new QueryResult<BaseItemDto>(dtos); } diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index b09b279699..4995d37f4b 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -458,6 +458,7 @@ public class DynamicHlsHelper { case VideoRangeType.HLG: case VideoRangeType.DOVIWithHLG: + case VideoRangeType.DOVIInvalid when string.Equals(state.VideoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase): builder.Append(",VIDEO-RANGE=HLG"); break; default: diff --git a/Jellyfin.Data/Enums/VideoRangeType.cs b/Jellyfin.Data/Enums/VideoRangeType.cs index ce232d73c3..e7cc340d21 100644 --- a/Jellyfin.Data/Enums/VideoRangeType.cs +++ b/Jellyfin.Data/Enums/VideoRangeType.cs @@ -61,8 +61,9 @@ public enum VideoRangeType DOVIWithELHDR10Plus, /// <summary> - /// Dolby Vision with invalid configuration. e.g. Profile 8 compat id 6. - /// When using this range, the server would assume the video is still HDR10 after removing the Dolby Vision metadata. + /// Dolby Vision with invalid configuration, e.g. Profile 8 compat id 6 or inconsistent base-layer color metadata. + /// The base layer is classified as HDR only when its transfer characteristics signal PQ or HLG. + /// Otherwise, it is classified as SDR. /// </summary> DOVIInvalid, diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index c8983480c7..4b42409d29 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -18,7 +18,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Data</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index c2cb644c59..6405c8c45d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -34,6 +34,40 @@ public static class BaseItemMapper /// </summary> private static readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>(); + private static UserData[] DetachUserData(BaseItemEntity entity) + { + if (entity.UserData is null || entity.UserData.Count == 0) + { + return []; + } + + var detached = new UserData[entity.UserData.Count]; + var index = 0; + foreach (var userData in entity.UserData) + { + detached[index++] = new UserData + { + ItemId = userData.ItemId, + Item = null, + UserId = userData.UserId, + User = null, + CustomDataKey = userData.CustomDataKey, + Rating = userData.Rating, + PlaybackPositionTicks = userData.PlaybackPositionTicks, + PlayCount = userData.PlayCount, + IsFavorite = userData.IsFavorite, + LastPlayedDate = userData.LastPlayedDate, + Played = userData.Played, + AudioStreamIndex = userData.AudioStreamIndex, + SubtitleStreamIndex = userData.SubtitleStreamIndex, + Likes = userData.Likes, + RetentionDate = userData.RetentionDate + }; + } + + return detached; + } + /// <summary> /// Maps a Entity to the DTO. /// </summary> @@ -87,7 +121,7 @@ public static class BaseItemMapper dto.OwnerId = entity.OwnerId ?? Guid.Empty; dto.Width = entity.Width.GetValueOrDefault(); dto.Height = entity.Height.GetValueOrDefault(); - dto.UserData = entity.UserData; + dto.UserData = DetachUserData(entity); if (entity.Provider is not null) { diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 2ff8131ade..51ac146a6f 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -117,24 +117,55 @@ public sealed partial class BaseItemRepository .ToArray(); } - private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) + /// <inheritdoc /> + public IReadOnlyList<string> GetTagNames(InternalItemsQuery filter) { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + using var context = _dbProvider.CreateDbContext(); + var baseQuery = PrepareItemQuery(context, filter); + baseQuery = TranslateQuery(baseQuery, context, filter); - var query = context.ItemValuesMap + var matchingItemIds = baseQuery.Select(e => e.Id); + + // Project the join before grouping. Grouping over the ItemValue navigation instead makes EF + // re-resolve the aggregate as a correlated subquery per group, which is orders of magnitude slower. + return context.ItemValuesMap .AsNoTracking() - .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type)); + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Tags) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) + .GroupBy(iv => iv.CleanValue) + .Select(g => g.Min(iv => iv.Value)!) + .OrderBy(t => t) + .ToArray(); + } + + private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) + { + using var context = _dbProvider.CreateDbContext(); + + var maps = context.ItemValuesMap.AsNoTracking(); if (withItemTypes.Count > 0) { - query = query.Where(e => withItemTypes.Contains(e.Item.Type)); + maps = maps.Where(e => withItemTypes.Contains(e.Item.Type)); } if (excludeItemTypes.Count > 0) { - query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type)); + maps = maps.Where(e => !excludeItemTypes.Contains(e.Item.Type)); } - return query.Select(e => e.ItemValue) + return context.ItemValues + .AsNoTracking() + .WhereOneOrMany(itemValueTypes, e => e.Type) + .Where(e => maps.Any(m => m.ItemValueId == e.ItemValueId)) + .Select(e => new { e.CleanValue, e.Value }) .GroupBy(e => e.CleanValue) .Select(g => g.Min(v => v.Value)!) .ToArray(); @@ -317,14 +348,7 @@ public sealed partial class BaseItemRepository .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; - var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo]; - var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram]; - var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) // Instead, start from ItemValueMaps and join with BaseItems. @@ -333,9 +357,9 @@ public sealed partial class BaseItemRepository scopedItems, ivm => ivm.ItemId, e => e.Id, - (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId }) + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId, e.Id }) .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId }) - .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() }) + .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Select(x => x.Id).Distinct().Count() }) .ToList(); // Only studios and genres pass down from a series to its episodes; an artist credit does not. @@ -357,46 +381,10 @@ public sealed partial class BaseItemRepository foreach (var group in rawCounts.GroupBy(x => x.CleanName)) { - var counts = new ItemCounts(); - foreach (var row in group) - { - if (row.Type == seriesTypeName) - { - counts.SeriesCount += row.Count; - } - else if (row.Type == movieTypeName) - { - counts.MovieCount += row.Count; - } - else if (row.Type == musicAlbumTypeName) - { - counts.AlbumCount += row.Count; - } - else if (row.Type == musicArtistTypeName) - { - counts.ArtistCount += row.Count; - } - else if (row.Type == musicVideoTypeName) - { - counts.MusicVideoCount += row.Count; - } - else if (row.Type == programTypeName) - { - counts.ProgramCount += row.Count; - } - else if (row.Type == audioTypeName) - { - counts.SongCount += row.Count; - } - else if (row.Type == trailerTypeName) - { - counts.TrailerCount += row.Count; - } - } + var counts = ItemCountBuilder.Build(_itemTypeLookup, group.Select(row => (row.Type, row.Count))); // Episodes are counted separately: the value is usually only written on the series. - counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key); - counts.ItemCount = counts.TotalItemCount(); + ItemCountBuilder.SetEpisodeCount(counts, episodeCounts.GetValueOrDefault(group.Key)); countsByCleanName[group.Key] = counts; } @@ -405,7 +393,9 @@ public sealed partial class BaseItemRepository { if (!countsByCleanName.ContainsKey(cleanName)) { - countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount }; + var counts = new ItemCounts(); + ItemCountBuilder.SetEpisodeCount(counts, episodeCount); + countsByCleanName[cleanName] = counts; } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index c0067d8392..1a4c9da41c 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -271,7 +271,7 @@ public sealed partial class BaseItemRepository if (filter.DtoOptions.EnableImages) { - dbQuery = dbQuery.Include(e => e.Images); + dbQuery = dbQuery.Include(e => e.Images!.OrderBy(i => i.Id)); } // Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist, @@ -291,7 +291,7 @@ public sealed partial class BaseItemRepository }; if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) { - dbQuery = dbQuery.Include(e => e.LinkedChildEntities); + dbQuery = dbQuery.Include(e => e.LinkedChildEntities!.OrderBy(l => l.SortOrder)); } if (filter.IncludeExtras) @@ -465,16 +465,23 @@ public sealed partial class BaseItemRepository baseQuery = ApplyParentalRestrictions(context, baseQuery, filter); - // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items. - // Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those. + // Hide alternate versions behind the primary of their library, and exclude owned non-extra + // items. Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those. if (!filter.IncludeOwnedItems) { - baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + baseQuery = ApplyAlternateVersionFiltering(context, baseQuery) + .Where(e => e.OwnerId == null || e.ExtraType != null); } return baseQuery; } + private static IQueryable<BaseItemEntity> ApplyAlternateVersionFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery) + => baseQuery.Where(e => e.PrimaryVersionId == null + || !context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId)); + /// <summary> /// Restricts a query to the libraries the user may open, exempting requested by-name items. /// </summary> @@ -645,24 +652,26 @@ public sealed partial class BaseItemRepository { var maxScore = maxRating.Score; var maxSubScore = maxRating.SubScore ?? 0; - var linkedChildren = context.LinkedChildren; + + // Only a manual link makes an item a container of other items. + var members = context.LinkedChildren + .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual); return e => - // Item has a rating: check against limit - (e.InheritedParentalRatingValue != null - && (e.InheritedParentalRatingValue < maxScore - || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))) - // Item has no rating - || (e.InheritedParentalRatingValue == null - && ( - // No linked children (not a BoxSet/Playlist): pass as unrated - !linkedChildren.Any(lc => lc.ParentId == e.Id) - // Has linked children: at least one child must be within limits - || linkedChildren.Any(lc => lc.ParentId == e.Id - && (lc.Child!.InheritedParentalRatingValue == null - || lc.Child.InheritedParentalRatingValue < maxScore - || (lc.Child.InheritedParentalRatingValue == maxScore - && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))))); + // The item's own rating, where it has one, has to be within the limit. An unrated item + // passes here; blocking those is what BlockUnratedItems does. + (e.InheritedParentalRatingValue == null + || e.InheritedParentalRatingValue < maxScore + || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)) + // A container is only as visible as its members: a BoxSet or Playlist with nothing left + // in it for this user is hidden whatever rating it carries itself. BoxSet.IsVisible + // applies the same rule in memory, and a count has to agree with the listing it counts. + && (!members.Any(lc => lc.ParentId == e.Id) + || members.Any(lc => lc.ParentId == e.Id + && (lc.Child!.InheritedParentalRatingValue == null + || lc.Child.InheritedParentalRatingValue < maxScore + || (lc.Child.InheritedParentalRatingValue == maxScore + && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))); } /// <inheritdoc /> diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index c9e08b1b5d..8d573569e9 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -593,10 +593,10 @@ public sealed partial class BaseItemRepository return dbContext.BaseItems .Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem) - .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + .All(BuildLeafIsPlayedFilter(dbContext, user.Id)); } - return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + return dbContext.BaseItems.Where(e => e.ParentId == id).All(BuildLeafIsPlayedFilter(dbContext, user.Id)); } /// <inheritdoc /> @@ -626,18 +626,26 @@ public sealed partial class BaseItemRepository .ToArray(); var tags = context.ItemValuesMap - .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags) - .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) - .Select(ivm => ivm.ItemValue) + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Tags) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) .GroupBy(iv => iv.CleanValue) .Select(g => g.Min(iv => iv.Value)) .OrderBy(t => t) .ToArray(); var genres = context.ItemValuesMap - .Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre) - .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) - .Select(ivm => ivm.ItemValue) + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Genre) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) .GroupBy(iv => iv.CleanValue) .Select(g => g.Min(iv => iv.Value)) .OrderBy(g => g) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index d635b38df5..d726f0f143 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -38,22 +38,32 @@ public sealed partial class BaseItemRepository // Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree. private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user) { - var userId = user.Id; + // Folders (Series, Seasons, BoxSets, albums, ...) carry no played state of their own and count + // as played once no descendant is left unplayed. + var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user) + .Where(BuildLeafIsPlayedFilter(context, user.Id).Not()); + + return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) + .Or(IsFolderFilter.Not().And(BuildLeafIsPlayedFilter(context, user.Id))); + } - // Leaf items carry their own played state. + private static Expression<Func<BaseItemEntity, bool>> BuildLeafIsPlayedFilter(JellyfinDbContext context, Guid userId) + { var playedItemIds = context.UserData .Where(ud => ud.UserId == userId && ud.Played) .Select(ud => ud.ItemId); - // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no - // descendant is left unplayed, matching what the DTO reports for them. This has to key off - // the item itself rather than off the requested item types: tag and collection listings mix - // folders and leaf items in a single query. - var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user) - .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + // The primaries of every version group holding a played row, whichever version carries it. + var playedGroupIds = context.BaseItems + .Where(v => v.PrimaryVersionId != null + && context.UserData.Any(ud => ud.UserId == userId + && ud.Played + && (ud.ItemId == v.Id || ud.ItemId == v.PrimaryVersionId))) + .Select(v => v.PrimaryVersionId!.Value); - return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) - .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id))); + return e => playedItemIds.Contains(e.Id) + || playedGroupIds.Contains(e.Id) + || (e.PrimaryVersionId != null && playedGroupIds.Contains(e.PrimaryVersionId.Value)); } // "und" is the language filters' stand-in for a track that declares no language at all. @@ -571,8 +581,8 @@ public sealed partial class BaseItemRepository .ToArray(); var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type)) .And(BuildHasDescendantFilter(context, inProgressLeafItems) - .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))))); + .Or(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId))) + .And(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId).Not()))))); if (isResumable) { @@ -797,11 +807,16 @@ public sealed partial class BaseItemRepository { // Exclude owned non-extra items from general queries. // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. - // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries - // keep them so the actually-played version can surface instead of collapsing onto the primary. - baseQuery = filter.IsResumable == true - ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null) - : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null); + + // Alternate versions (PrimaryVersionId set) are normally hidden behind their primary, but + // resume queries keep them so the actually-played version can surface instead of collapsing + // onto the primary, and the library scan keeps them so a merged version is not mistaken for + // a new item. + if (filter.IsResumable != true && !filter.IncludeAlternateVersions) + { + baseQuery = ApplyAlternateVersionFiltering(context, baseQuery); + } } if (filter.OwnerIds.Length > 0) diff --git a/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs new file mode 100644 index 0000000000..0f8a1b9dfe --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Turns per-type counts into an <see cref="ItemCounts"/>. +/// </summary> +internal static class ItemCountBuilder +{ + /// <summary> + /// Builds the counts of one by-name item. + /// </summary> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="counts">The counted items, by type name. A type may repeat.</param> + /// <returns>The counts.</returns> + public static ItemCounts Build(IItemTypeLookup itemTypeLookup, IEnumerable<(string Type, int Count)> counts) + { + ArgumentNullException.ThrowIfNull(itemTypeLookup); + ArgumentNullException.ThrowIfNull(counts); + + var lookup = itemTypeLookup.BaseItemKindNames; + var result = new ItemCounts(); + + foreach (var (type, count) in counts) + { + // Accumulated rather than assigned: a caller may group by something finer than the + // type and hand the same type over more than once. + if (string.Equals(type, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) + { + result.AlbumCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) + { + result.ArtistCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) + { + result.EpisodeCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) + { + result.MovieCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) + { + result.MusicVideoCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) + { + result.ProgramCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Series], StringComparison.Ordinal)) + { + result.SeriesCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + { + result.SongCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + { + result.TrailerCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + { + result.BoxSetCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + { + result.BookCount += count; + } + } + + result.ItemCount = result.TotalItemCount(); + + return result; + } + + /// <summary> + /// Replaces the episode count, which both by-name paths decide separately from the other + /// types because a genre or studio is usually written on the series rather than its episodes. + /// </summary> + /// <param name="counts">The counts to update.</param> + /// <param name="episodeCount">The episode count.</param> + public static void SetEpisodeCount(ItemCounts counts, int episodeCount) + { + ArgumentNullException.ThrowIfNull(counts); + + counts.EpisodeCount = episodeCount; + counts.ItemCount = counts.TotalItemCount(); + } +} diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 704dc31fd0..942161a176 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -7,6 +7,7 @@ using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Persistence; @@ -125,178 +126,286 @@ public class ItemCountService : IItemCountService /// <inheritdoc /> public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) { - using var context = _dbProvider.CreateDbContext(); + return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id]; + } - var item = context.BaseItems.AsNoTracking() - .Where(e => e.Id == id) - .Select(e => new { e.Name, e.CleanName }) - .FirstOrDefault(); + private static ItemValueType[] GetItemValueTypes(BaseItemKind kind) + => kind switch + { + BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist], + BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre], + BaseItemKind.Studio => [ItemValueType.Studios], + _ => [] + }; - if (item is null) + /// <inheritdoc /> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(relatedItemKinds); + ArgumentNullException.ThrowIfNull(accessFilter); + + var result = new Dictionary<Guid, ItemCounts>(); + if (ids.Count == 0) { - return new ItemCounts(); + return result; } - IQueryable<BaseItemEntity> baseQuery; - switch (kind) + using var context = _dbProvider.CreateDbContext(); + + var idsArray = ids as Guid[] ?? ids.ToArray(); + var nameItems = context.BaseItems.AsNoTracking() + .WhereOneOrMany(idsArray, e => e.Id) + .Select(e => new NameItem(e.Id, e.Name, e.CleanName)) + .ToArray(); + + foreach (var id in ids) { - case BaseItemKind.Person: - baseQuery = ItemsById(context, context.PeopleBaseItemMap - .AsNoTracking() - .Where(m => m.People.Name == item.Name) - .Select(m => m.ItemId)); - break; - case BaseItemKind.MusicArtist: - baseQuery = ItemsById(context, context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist)) - .Select(ivm => ivm.ItemId)); - break; - case BaseItemKind.Genre: - case BaseItemKind.MusicGenre: - baseQuery = ItemsById(context, context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && ivm.ItemValue.Type == ItemValueType.Genre) - .Select(ivm => ivm.ItemId)); - break; - case BaseItemKind.Studio: - baseQuery = ItemsById(context, context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && ivm.ItemValue.Type == ItemValueType.Studios) - .Select(ivm => ivm.ItemId)); - break; - case BaseItemKind.Year: - if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) - { - baseQuery = context.BaseItems - .AsNoTracking() - .Where(e => e.ProductionYear == year); - } - else - { - return new ItemCounts(); - } + result[id] = new ItemCounts(); + } - break; - default: - return new ItemCounts(); + if (nameItems.Length == 0) + { + return result; } var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray(); - baseQuery = baseQuery.Where(e => typeNames.Contains(e.Type)); + var related = _queryHelpers.ApplyAccessFiltering( + context, + context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)), + accessFilter); - baseQuery = _queryHelpers.ApplyAccessFiltering(context, baseQuery, accessFilter); + var valueTypes = GetItemValueTypes(kind); + if (valueTypes.Length > 0) + { + CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result); + } + else if (kind == BaseItemKind.Person) + { + CountByPersonName(context, related, nameItems, result); + } + else if (kind == BaseItemKind.Year) + { + CountByProductionYear(related, nameItems, result); + } - var counts = baseQuery - .GroupBy(x => x.Type) - .Select(x => new { x.Key, Count = x.Count() }) - .ToArray(); + return result; + } - var lookup = _itemTypeLookup.BaseItemKindNames; - var result = new ItemCounts(); - var totalCount = 0; + private void CountByItemValue( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + BaseItemKind kind, + BaseItemKind[] relatedItemKinds, + ItemValueType[] valueTypes, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var cleanNames = nameItems + .Select(n => n.CleanName) + .OfType<string>() + .Distinct(StringComparer.Ordinal) + .ToArray(); - foreach (var count in counts) + if (cleanNames.Length == 0) { - totalCount += count.Count; + return; + } - if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) - { - result.AlbumCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) - { - result.ArtistCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) - { - result.EpisodeCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) - { - result.MovieCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) - { - result.MusicVideoCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) - { - result.ProgramCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal)) - { - result.SeriesCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) - { - result.SongCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + var grouped = context.ItemValuesMap.AsNoTracking() + .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue) + .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id }) + .GroupBy(x => new { x.CleanValue, x.Type }) + .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + var byCleanName = grouped + .GroupBy(g => g.CleanValue, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds) + && Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal)) + ? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames) + : null; + + foreach (var nameItem in nameItems) + { + if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts)) { - result.TrailerCount = count.Count; + continue; } - else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + + var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts); + + if (episodeRollUp is not null) { - result.BoxSetCount = count.Count; + var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName); + + // Episodes of a tagged series count towards it even when untagged themselves, and + // a tagged episode of a tagged series must not be counted a second time. + var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries; + ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount); } - else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + + result[nameItem.Id] = itemCounts; + } + } + + private void CountByPersonName( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var names = nameItems + .Select(n => n.Name) + .OfType<string>() + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (names.Length == 0) + { + return; + } + + var grouped = context.PeopleBaseItemMap.AsNoTracking() + .WhereOneOrMany(names, m => m.People.Name) + .Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id }) + .GroupBy(x => new { x.Name, x.Type }) + // A person can be credited on one item more than once, in different roles. + .Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result); + } + + private void CountByProductionYear( + IQueryable<BaseItemEntity> related, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var years = new List<int>(); + foreach (var nameItem in nameItems) + { + if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) + && !years.Contains(year)) { - result.BookCount = count.Count; + years.Add(year); } } - if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre - && relatedItemKinds.Contains(BaseItemKind.Episode) - && relatedItemKinds.Contains(BaseItemKind.Series)) + if (years.Count == 0) { - var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount); - totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount; - result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount; + return; } - result.ItemCount = totalCount; + // No join, so no row can be reached twice and a plain count is the distinct count. + var grouped = related + .Where(e => e.ProductionYear != null) + .WhereOneOrMany(years, e => e.ProductionYear!.Value) + .GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type }) + .Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() }) + .ToArray(); - return result; + var byYear = grouped + .GroupBy(g => g.Year) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray()); + + foreach (var nameItem in nameItems) + { + if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) + && byYear.TryGetValue(year, out var counts)) + { + result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); + } + } } - private int CountEpisodesOfTaggedSeries( + private void ApplyGroupedCounts( + NameItem[] nameItems, + Func<NameItem, string?> keySelector, + IEnumerable<(string Key, string Type, int Count)> grouped, + Dictionary<Guid, ItemCounts> result) + { + var byKey = grouped + .GroupBy(g => g.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); + + foreach (var nameItem in nameItems) + { + var key = keySelector(nameItem); + if (key is not null && byKey.TryGetValue(key, out var counts)) + { + result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); + } + } + } + + private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds) + => kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre + && relatedItemKinds.Contains(BaseItemKind.Episode) + && relatedItemKinds.Contains(BaseItemKind.Series); + + private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeriesByCleanName( JellyfinDbContext context, - IQueryable<BaseItemEntity> taggedItems, - InternalItemsQuery accessFilter, - out int unrelatedEpisodeCount) + IQueryable<BaseItemEntity> related, + ItemValueType[] valueTypes, + string[] cleanNames) { var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id); - unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName - && (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value))); + var taggedValues = context.ItemValuesMap.AsNoTracking() + .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); + + // The series carrying each clean name. Distinct, because one item can be mapped to the + // same clean name once per value type. + var taggedSeries = taggedValues + .Join( + related.Where(e => e.Type == seriesTypeName), + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id }) + .Distinct(); + + var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null); + + var episodesOfTaggedSeries = taggedSeries + .Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id }) + .GroupBy(x => x.CleanValue) + .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); - // Materialised so the episode count drives off IX_BaseItems_SeriesId. - var seriesIds = taggedItems - .Where(e => e.Type == seriesTypeName) - .Select(e => e.Id) + // Episodes that carry the clean name themselves *and* belong to a series carrying it. The + // roll-up already counts those, so they have to come off the directly tagged ones. + var taggedEpisodesOfTaggedSeries = taggedValues + .Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId }) + .Join( + taggedSeries, + e => new { e.CleanValue, SeriesId = e.SeriesId!.Value }, + s => new { s.CleanValue, s.SeriesId }, + (e, s) => new { e.CleanValue, e.Id }) + .GroupBy(x => x.CleanValue) + .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) .ToArray(); - if (seriesIds.Length == 0) + var taggedLookup = taggedEpisodesOfTaggedSeries + .ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal); + + // Every clean name in taggedLookup came from an episode of a tagged series, so it always + // has a row in episodesOfTaggedSeries too - no second merge pass is needed. + var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringComparer.Ordinal); + foreach (var entry in episodesOfTaggedSeries) { - return 0; + result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue)); } - var episodes = context.BaseItems.AsNoTracking() - .Where(e => e.Type == episodeTypeName && e.SeriesId != null) - .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value); - - return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count(); + return result; } - private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds) - => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id)); - /// <inheritdoc/> public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { @@ -305,7 +414,7 @@ public class ItemCountService : IItemCountService using var dbContext = _dbProvider.CreateDbContext(); var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); - return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); + return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id)); } /// <inheritdoc/> @@ -374,7 +483,19 @@ public class ItemCountService : IItemCountService var includeVirtual = user is null || user.DisplayMissingEpisodes; - var hierarchicalCounts = dbContext.BaseItems + var accessibleItems = dbContext.BaseItems.AsNoTracking(); + if (user is null) + { + // Access filtering is what would otherwise drop an alternate version, and a child count + // must not report a title twice just because no user was passed in. + accessibleItems = accessibleItems.Where(DescendantQueryHelper.IsDistinctLibraryItem); + } + else + { + accessibleItems = _queryHelpers.ApplyAccessFiltering(dbContext, accessibleItems, new InternalItemsQuery(user)); + } + + var hierarchicalCounts = accessibleItems .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value) .GroupBy(b => b.ParentId!.Value) @@ -384,20 +505,22 @@ public class ItemCountService : IItemCountService // An episode is a child of its season even when it is not stored under one: with a flat // structure ParentId points at the series, so counting by ParentId alone leaves the season // empty and counts its episodes towards the series instead. - var seasonCounts = dbContext.BaseItems + var seasonCounts = accessibleItems .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value) .GroupBy(b => b.SeasonId!.Value) .Select(g => new { SeasonId = g.Key, Count = g.Count() }) .ToDictionary(x => x.SeasonId, x => x.Count); + // A linked child counts only when the item it points at is one the user may open. var linkedCounts = dbContext.LinkedChildren .WhereOneOrMany(parentIdsArray, lc => lc.ParentId) - .GroupBy(lc => lc.ParentId) + .Join(accessibleItems, lc => lc.ChildId, b => b.Id, (lc, b) => lc.ParentId) + .GroupBy(parentId => parentId) .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); - var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual); + var mergedChildCounts = GetMergedChildCounts(dbContext, accessibleItems, parentIdsArray, includeVirtual); var result = new Dictionary<Guid, int>(); foreach (var parentId in parentIds) @@ -418,7 +541,11 @@ public class ItemCountService : IItemCountService return result; } - private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual) + private static Dictionary<Guid, int> GetMergedChildCounts( + JellyfinDbContext dbContext, + IQueryable<BaseItemEntity> accessibleItems, + IReadOnlyList<Guid> parentIds, + bool includeVirtual) { var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds) .Where(group => group.Value.Count > 1) @@ -431,14 +558,12 @@ public class ItemCountService : IItemCountService // Only merged folders. var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray(); - var children = dbContext.BaseItems - .AsNoTracking() + var children = accessibleItems .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(memberIds, b => b.ParentId!.Value) .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey }) .ToArray() - .Concat(dbContext.BaseItems - .AsNoTracking() + .Concat(accessibleItems .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(memberIds, b => b.SeasonId!.Value) .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey }) @@ -492,7 +617,7 @@ public class ItemCountService : IItemCountService leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); + .Select(DescendantQueryHelper.PlayedStateBy(userId)); var ancestorLeaves = dbContext.AncestorIds .WhereOneOrMany(folderIdsArray, a => a.ParentItemId) @@ -610,7 +735,7 @@ public class ItemCountService : IItemCountService private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId) { var result = query - .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played)) + .Select(DescendantQueryHelper.IsPlayedBy(userId)) .GroupBy(_ => 1) .OrderBy(g => g.Key) .Select(g => new @@ -622,4 +747,12 @@ public class ItemCountService : IItemCountService return result is null ? (0, 0) : (result.Played, result.Total); } + + /// <summary> + /// A by-name item, reduced to the three columns the counting keys off. + /// </summary> + /// <param name="Id">The id of the by-name item.</param> + /// <param name="Name">The name of the by-name item.</param> + /// <param name="CleanName">The cleaned name of the by-name item.</param> + private sealed record NameItem(Guid Id, string? Name, string? CleanName); } diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index efff3457a3..a7b0b1c1fc 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -68,16 +69,24 @@ public class ItemPersistenceService : IItemPersistenceService // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a // single parameter (json_each) rather than one SQL variable per id, which would otherwise // overflow SQLite's variable limit when deleting many items at once (e.g. migrations). - var ownerIds = descendantIds.ToArray(); - var extraIds = context.BaseItems - .Where(e => e.OwnerId.HasValue) - .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value) - .Select(e => e.Id) - .ToArray(); - - foreach (var extraId in extraIds) + var frontier = descendantIds.ToArray(); + while (frontier.Length > 0) { - descendantIds.Add(extraId); + var ownedIds = context.BaseItems + .Where(e => e.OwnerId.HasValue) + .WhereOneOrMany(frontier, e => e.OwnerId!.Value) + .Select(e => e.Id) + .ToArray(); + + var childIds = context.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(frontier, e => e.ParentId!.Value) + .Select(e => e.Id) + .ToArray(); + + // Only ids that were not already known become the next frontier, so ownership cycles + // terminate instead of looping forever. + frontier = [.. ownedIds.Concat(childIds).Where(e => descendantIds.Add(e))]; } var relatedItems = descendantIds.ToArray(); @@ -136,13 +145,13 @@ public class ItemPersistenceService : IItemPersistenceService context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete(); context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete(); + var peopleIds = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray(); context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray(); context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); + context.Peoples.WhereOneOrMany(peopleIds, e => e.Id).Where(e => !e.BaseItems!.Any()).ExecuteDelete(); context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.SaveChanges(); transaction.Commit(); @@ -176,14 +185,6 @@ public class ItemPersistenceService : IItemPersistenceService var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - if (!await context.BaseItems - .AnyAsync(bi => bi.Id == item.Id, cancellationToken) - .ConfigureAwait(false)) - { - _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); - return; - } - await context.BaseItemImageInfos .Where(e => e.ItemId == item.Id) .ExecuteDeleteAsync(cancellationToken) @@ -193,7 +194,26 @@ public class ItemPersistenceService : IItemPersistenceService .AddRangeAsync(images, cancellationToken) .ConfigureAwait(false); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + try + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException) + { + // Checking that the item exists before writing leaves a gap a scan can delete it + // through, turning the insert into a foreign key violation that fails the whole + // refresh instead of the no-op intended here. Let the insert be the check: it is the + // only point at which the answer cannot go stale. Nothing is orphaned by the delete + // above, because deleting the item cascades to its images anyway. + if (await context.BaseItems + .AnyAsync(bi => bi.Id == item.Id, cancellationToken) + .ConfigureAwait(false)) + { + throw; + } + + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id); + } } } @@ -257,7 +277,7 @@ public class ItemPersistenceService : IItemPersistenceService using var transaction = context.Database.BeginTransaction(); var ids = tuples.Select(f => f.Item.Id).ToArray(); - var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet(); + var existingItems = context.BaseItems.WhereOneOrMany(ids, e => e.Id).Select(f => f.Id).ToHashSet(); foreach (var item in tuples) { @@ -317,7 +337,7 @@ public class ItemPersistenceService : IItemPersistenceService .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray())) .ToArray(); - var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); + var mappedValues = context.ItemValuesMap.WhereOneOrMany(ids, e => e.ItemId).ToList(); foreach (var item in valueMap) { @@ -646,6 +666,38 @@ public class ItemPersistenceService : IItemPersistenceService sortOrder++; } + var linkedChildIds = newLinkedChildren + .Select(c => c.ChildId) + // A video listed among its own versions would be pointed at itself. + .Where(childId => existingChildIds.Contains(childId) && !childId.Equals(video.Id)) + .Where(childId => !childId.Equals(video.PrimaryVersionId)) + .ToList(); + if (linkedChildIds.Count > 0) + { + var demotedChildren = context.BaseItems + .Where(e => linkedChildIds.Contains(e.Id) + && (e.PrimaryVersionId == null || e.PrimaryVersionId != video.Id)) + .ToList(); + + foreach (var child in demotedChildren) + { + child.PrimaryVersionId = video.Id; + + // Mirrors Video.CreatePresentationUniqueKey, so presentation-key grouping + // collapses the version onto its primary as well. + child.PresentationUniqueKey = video.Id.ToString("N", CultureInfo.InvariantCulture); + } + + if (demotedChildren.Count > 0) + { + _logger.LogInformation( + "Set PrimaryVersionId on {Count} alternate versions of video {VideoName} ({VideoId})", + demotedChildren.Count, + video.Name, + video.Id); + } + } + // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned; var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id); if (previousLinkedChildren is { Count: > 0 }) diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs index f478daef23..897fb98cbb 100644 --- a/Jellyfin.Server.Implementations/Item/NextUpService.cs +++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs @@ -95,7 +95,7 @@ public class NextUpService : INextUpService .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsPlayedBy(userId)); lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter); // Use lightweight projection + client-side dedup to avoid the correlated scalar subquery @@ -129,12 +129,21 @@ public class NextUpService : INextUpService // Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation. // SelectMany on UserData with a correlated Where would translate to APPLY, // which SQLite does not support. + // Access filtering leaves only primaries in the base query, but a play can be recorded + // against any version, so each row is attributed to its group's primary before the join. + var playedByGroupPrimary = context.UserData + .AsNoTracking() + .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)) + .Where(ud => ud.Played) + .Join( + context.BaseItems.AsNoTracking(), + ud => ud.ItemId, + bi => bi.Id, + (ud, bi) => new { ud.UserId, ItemId = bi.PrimaryVersionId ?? bi.Id, ud.LastPlayedDate }); + var playedWithDates = lastWatchedByDateBase .Join( - context.UserData - .AsNoTracking() - .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)) - .Where(ud => ud.Played), + playedByGroupPrimary, e => new { UserId = userId, ItemId = e.Id }, ud => new { ud.UserId, ud.ItemId }, (e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate }) @@ -198,7 +207,7 @@ public class NextUpService : INextUpService .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) - .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsUnplayedBy(userId)); allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter); var allUnplayedCandidates = allUnplayedBase .Select(e => new @@ -246,7 +255,7 @@ public class NextUpService : INextUpService .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsPlayedBy(userId)); allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter); var allPlayedCandidates = allPlayedBase .Select(e => new diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index da2ad033ec..fcddc09ad9 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -127,18 +127,61 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray(); var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray(); - var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray(); using var context = _dbProvider.CreateDbContext(); + var existingMaps = context.PeopleBaseItemMap + .AsNoTracking() + .Include(e => e.People) + .Where(e => e.ItemId == itemId) + .ToList(); + + // Most library scans refresh unchanged local metadata. Avoid opening a write + // transaction when the item's people mappings, order and roles are unchanged. + var incomingCredits = distinctCredits + .Select((credit, index) => new + { + Key = (credit.LoweredName, credit.PersonType, credit.LoweredRole), + Role = credit.Person.Role, + ListOrder = index, + SortOrder = credit.Person.SortOrder + }) + .ToDictionary(e => e.Key); + var mappingsAreUnchanged = existingMaps.Count == incomingCredits.Count + && existingMaps.All(map => + incomingCredits.TryGetValue( + (map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), + out var incoming) + && map.ListOrder == incoming.ListOrder + && map.SortOrder == incoming.SortOrder + && string.Equals(map.Role ?? string.Empty, incoming.Role, StringComparison.OrdinalIgnoreCase)); + + if (mappingsAreUnchanged) + { + return; + } + using var transaction = context.Database.BeginTransaction(); - var existingPersons = context.Peoples.Select(e => new + // The fast-path snapshot was read before acquiring the write transaction. Reload + // tracked mappings inside it so a concurrent refresh cannot leave stale credits. + existingMaps = context.PeopleBaseItemMap + .Include(e => e.People) + .Where(e => e.ItemId == itemId) + .ToList(); + + // Query each person type separately so SQLite can use IX_Peoples_NameLower. + // Combining the two fields into `lower(Name) || '-' || PersonType` forces a full + // scan of Peoples for every media item, which is prohibitive during a large import. + var existingPersons = new List<People>(); + foreach (var personTypeGroup in distinctPersons.GroupBy(e => e.PersonType, StringComparer.Ordinal)) { - item = e, - SelectionKey = e.Name.ToLower() + "-" + e.PersonType - }) - .Where(p => personKeys.Contains(p.SelectionKey)) - .Select(f => f.item) - .ToArray(); + var names = personTypeGroup + .Select(e => e.LoweredName) + .ToArray(); + + existingPersons.AddRange(context.Peoples + .Where(e => e.PersonType == personTypeGroup.Key && names.Contains(e.Name.ToLower())) + .ToArray()); + } var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet(); @@ -157,7 +200,6 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity); } - var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList(); var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>(); foreach (var map in existingMaps) { @@ -238,7 +280,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I using var context = _dbProvider.CreateDbContext(); var query = context.PeopleBaseItemMap .AsNoTracking() - .Where(m => itemIds.Contains(m.ItemId)); + .WhereOneOrMany(itemIds, m => m.ItemId); if (personTypes.Count > 0) { @@ -274,7 +316,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I using var context = _dbProvider.CreateDbContext(); var rows = context.PeopleBaseItemMap .AsNoTracking() - .Where(m => itemIds.Contains(m.ItemId)) + .WhereOneOrMany(itemIds, m => m.ItemId) .OrderBy(m => m.ListOrder) .Select(m => new { diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs index 92e2bb4fa7..7c46ef7721 100644 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data; @@ -9,6 +10,7 @@ using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Implementations.Users; @@ -20,6 +22,7 @@ public sealed class DeviceAccessHost : IHostedService private readonly IUserManager _userManager; private readonly IDeviceManager _deviceManager; private readonly ISessionManager _sessionManager; + private readonly ILogger<DeviceAccessHost> _logger; /// <summary> /// Initializes a new instance of the <see cref="DeviceAccessHost"/> class. @@ -27,11 +30,17 @@ public sealed class DeviceAccessHost : IHostedService /// <param name="userManager">The <see cref="IUserManager"/>.</param> /// <param name="deviceManager">The <see cref="IDeviceManager"/>.</param> /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param> - public DeviceAccessHost(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager) + /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param> + public DeviceAccessHost( + IUserManager userManager, + IDeviceManager deviceManager, + ISessionManager sessionManager, + ILogger<DeviceAccessHost> logger) { _userManager = userManager; _deviceManager = deviceManager; _sessionManager = sessionManager; + _logger = logger; } /// <inheritdoc /> @@ -53,9 +62,18 @@ public sealed class DeviceAccessHost : IHostedService private async void OnUserUpdated(object? sender, GenericEventArgs<User> e) { var user = e.Argument; - if (!user.HasPermission(PermissionKind.EnableAllDevices)) + + // This handler is async void, so an escaping exception would terminate the process. + try + { + if (!user.HasPermission(PermissionKind.EnableAllDevices)) + { + await UpdateDeviceAccess(user).ConfigureAwait(false); + } + } + catch (Exception ex) { - await UpdateDeviceAccess(user).ConfigureAwait(false); + _logger.LogError(ex, "Error updating device access for user {UserId}", user.Id); } } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index fea6084267..b15f6b98b2 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -847,14 +847,16 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { + User user; using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var user = UserQuery(dbContext) + user = await UserQuery(dbContext) .AsTracking() - .FirstOrDefault(u => u.Id.Equals(userId)) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false) ?? throw new ArgumentException("No user exists with given Id!"); // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" @@ -919,6 +921,10 @@ namespace Jellyfin.Server.Implementations.Users await dbContext.SaveChangesAsync().ConfigureAwait(false); } } + + var eventArgs = new UserUpdatedEventArgs(user); + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); + OnUserUpdated?.Invoke(this, eventArgs); } /// <inheritdoc/> diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index beafc3916f..a5b6bc4604 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -183,7 +183,13 @@ internal class JellyfinMigrationService } } - public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider) + /// <summary> + /// Runs all pending migrations of the requested stage. + /// </summary> + /// <param name="stage">The stage to migrate.</param> + /// <param name="serviceProvider">The service provider handed to the migrations.</param> + /// <returns>A value indicating whether at least one migration has been applied.</returns> + public async Task<bool> MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider serviceProvider) { var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}."); ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? []; @@ -297,6 +303,8 @@ internal class JellyfinMigrationService completedMigrations++; } + + return completedMigrations > 0; } } @@ -445,10 +453,10 @@ internal class JellyfinMigrationService private class InternalCodeMigration : IInternalMigration { private readonly CodeMigration _codeMigration; - private readonly IServiceProvider? _serviceProvider; + private readonly IServiceProvider _serviceProvider; private JellyfinDbContext _dbContext; - public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext) + public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider serviceProvider, JellyfinDbContext dbContext) { _codeMigration = codeMigration; _serviceProvider = serviceProvider; diff --git a/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs index 8a0a1741f1..291de23b2e 100644 --- a/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs @@ -29,7 +29,7 @@ internal class MigrateLibraryUserData : IAsyncMigrationRoutine private readonly IDbContextFactory<JellyfinDbContext> _provider; public MigrateLibraryUserData( - IStartupLogger<MigrateLibraryDb> startupLogger, + IStartupLogger<MigrateLibraryUserData> startupLogger, IDbContextFactory<JellyfinDbContext> provider, IServerApplicationPaths paths) { diff --git a/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs index 502763ac09..c8ee44a670 100644 --- a/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs +++ b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs @@ -24,7 +24,7 @@ internal class ReseedFolderFlag : IAsyncMigrationRoutine private readonly IDbContextFactory<JellyfinDbContext> _provider; public ReseedFolderFlag( - IStartupLogger<MigrateLibraryDb> startupLogger, + IStartupLogger<ReseedFolderFlag> startupLogger, IDbContextFactory<JellyfinDbContext> provider, IServerApplicationPaths paths) { diff --git a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs index 3fc2387e09..8eefbdb63a 100644 --- a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs +++ b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs @@ -156,6 +156,7 @@ internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false); await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false); + await MoveRemainingReferencesAsync(dbContext, newParentId, staleIds, cancellationToken).ConfigureAwait(false); // Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last. await dbContext.BaseItems @@ -171,6 +172,31 @@ internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine canonicalId); } + private static async Task MoveRemainingReferencesAsync( + JellyfinDbContext dbContext, + Guid? canonicalId, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + await dbContext.BaseItems + .Where(e => e.OwnerId.HasValue) + .WhereOneOrMany(staleIds, e => e.OwnerId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.OwnerId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + // Keyed by (ParentId, SortOrder), so these cannot be repointed onto the canonical view + // without risking a collision, and a view listing linked children is meaningless anyway. + await dbContext.LinkedChildren + .WhereOneOrMany(staleIds, e => e.ParentId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + await dbContext.LinkedChildren + .WhereOneOrMany(staleIds, e => e.ChildId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + private async Task<UserView> PickSourceAsync( JellyfinDbContext dbContext, IReadOnlyList<UserView> stale, @@ -294,8 +320,10 @@ internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine IReadOnlyList<Guid> staleIds, CancellationToken cancellationToken) { + // Ancestry recorded against items that no longer exist is dead weight. var items = await dbContext.AncestorIds .WhereOneOrMany(staleIds, e => e.ParentItemId) + .Where(e => dbContext.BaseItems.Any(item => item.Id.Equals(e.ItemId))) .Select(e => e.ItemId) .Distinct() .ToListAsync(cancellationToken) diff --git a/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs b/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs new file mode 100644 index 0000000000..43db38c92f --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Re-points every video that is linked as an alternate version at the primary it belongs to. +/// </summary> +[JellyfinMigration("2026-09-08T12:00:00", nameof(RepairAlternateVersionLinks))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class RepairAlternateVersionLinks : IAsyncMigrationRoutine +{ + private const int BatchSize = 1000; + + private readonly IStartupLogger<RepairAlternateVersionLinks> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RepairAlternateVersionLinks"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbProvider">The database context factory.</param> + public RepairAlternateVersionLinks( + IStartupLogger<RepairAlternateVersionLinks> logger, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var links = await dbContext.LinkedChildren + .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion + || lc.ChildType == LinkedChildType.LinkedAlternateVersion) + .Select(lc => new { lc.ParentId, lc.ChildId, lc.ChildType }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (links.Count == 0) + { + _logger.LogInformation("No alternate version links found, nothing to repair."); + return; + } + + // A version belongs to one primary; a file-based link outranks a user-merged one, as it + // does everywhere else these two link types meet. + var primaryByChild = links + .GroupBy(l => l.ChildId) + .ToDictionary( + g => g.Key, + g => g.OrderBy(l => l.ChildType == LinkedChildType.LocalAlternateVersion ? 0 : 1) + .First() + .ParentId); + + // A version that is its own primary would be hidden from every list by the repair below. + foreach (var selfLink in primaryByChild.Where(kvp => kvp.Value.Equals(kvp.Key)).ToList()) + { + _logger.LogWarning("Skipping alternate version {ChildId}, which is linked to itself.", selfLink.Key); + primaryByChild.Remove(selfLink.Key); + } + + ResolvePrimaries(primaryByChild); + + var repaired = 0; + var promoted = 0; + + // The primaries are loaded along with their versions: a primary that carries a + // PrimaryVersionId of its own hides the whole group it heads. + var itemIds = primaryByChild.Keys.Concat(primaryByChild.Values).Distinct().ToList(); + for (var offset = 0; offset < itemIds.Count; offset += BatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batch = itemIds.GetRange(offset, Math.Min(BatchSize, itemIds.Count - offset)); + var items = await dbContext.BaseItems + .Where(e => batch.Contains(e.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var item in items) + { + if (primaryByChild.TryGetValue(item.Id, out var primaryId)) + { + // Mirrors Video.CreatePresentationUniqueKey for a video that has a primary. + var expectedKey = primaryId.ToString("N", CultureInfo.InvariantCulture); + if (item.PrimaryVersionId.HasValue + && primaryId.Equals(item.PrimaryVersionId.Value) + && string.Equals(item.PresentationUniqueKey, expectedKey, StringComparison.Ordinal)) + { + continue; + } + + item.PrimaryVersionId = primaryId; + item.PresentationUniqueKey = expectedKey; + repaired++; + } + else if (item.PrimaryVersionId.HasValue) + { + if (item.OwnerId.HasValue) + { + // An owned item is hidden by its owner rather than by its primary, so + // clearing the primary here would not bring the group back. + _logger.LogWarning( + "Alternate versions are linked to {ItemId}, which is owned by {OwnerId}; the group stays hidden until the owner is repaired.", + item.Id, + item.OwnerId.Value); + continue; + } + + // Nothing links this one as a version, so the leftover primary is stale and + // would hide it, and with it every version linked to it. + _logger.LogWarning( + "Clearing the stale primary {PrimaryVersionId} of {ItemId}, which other versions are linked to.", + item.PrimaryVersionId.Value, + item.Id); + + item.PrimaryVersionId = null; + item.PresentationUniqueKey = item.Id.ToString("N", CultureInfo.InvariantCulture); + promoted++; + } + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation( + "Repaired {Repaired} of {Total} alternate version links, and promoted {Promoted} primaries that were versions themselves.", + repaired, + primaryByChild.Count, + promoted); + } + } + + private void ResolvePrimaries(Dictionary<Guid, Guid> primaryByChild) + { + var resolvedPrimaries = new Dictionary<Guid, Guid>(primaryByChild.Count); + + foreach (var start in primaryByChild.Keys.ToList()) + { + if (resolvedPrimaries.ContainsKey(start)) + { + continue; + } + + var chain = new List<Guid>(); + var walked = new HashSet<Guid>(); + var current = start; + Guid primary; + + while (true) + { + if (resolvedPrimaries.TryGetValue(current, out var resolved)) + { + primary = resolved; + break; + } + + if (!primaryByChild.TryGetValue(current, out var next)) + { + // Nothing is linking this one as a version of something else, so it heads the group. + primary = current; + break; + } + + if (!walked.Add(current)) + { + var loop = chain.Skip(chain.IndexOf(current)).ToList(); + + // Which member heads the group is arbitrary; the lowest id keeps the repair + // stable if the migration is ever re-run over the same data. + primary = loop.Min(); + _logger.LogWarning( + "Alternate version links form a loop ({Loop}); keeping {PrimaryId} as the primary of the group.", + string.Join(" -> ", loop), + primary); + + primaryByChild.Remove(primary); + break; + } + + chain.Add(current); + current = next; + } + + foreach (var version in chain) + { + resolvedPrimaries[version] = primary; + } + } + + foreach (var (version, primary) in resolvedPrimaries) + { + if (primary.Equals(version)) + { + // The member of a loop that was kept as the primary of its group. + continue; + } + + primaryByChild[version] = primary; + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs index ed92c34aa3..a456acf47d 100644 --- a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/20260910120000_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-03-02T09:00:00", nameof(MigrateRatingLevels))] +[JellyfinMigration("2026-09-10T12:00:00", nameof(MigrateRatingLevels))] [JellyfinMigrationBackup(JellyfinDb = true)] #pragma warning restore CS0618 // Type or member is obsolete internal class MigrateRatingLevels : IDatabaseMigrationRoutine diff --git a/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs new file mode 100644 index 0000000000..f61e42337a --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs @@ -0,0 +1,44 @@ +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Drops keys that no current property reads or writes from the serialized <c>BaseItems.Data</c> blob. +/// </summary> +[JellyfinMigration("2026-09-11T12:00:00", nameof(StripEmbeddedLinkedChildren))] +internal class StripEmbeddedLinkedChildren : IDatabaseMigrationRoutine +{ + private readonly ILogger<StripEmbeddedLinkedChildren> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + public StripEmbeddedLinkedChildren( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = loggerFactory.CreateLogger<StripEmbeddedLinkedChildren>(); + _dbProvider = dbProvider; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + // json_valid guards the rare malformed blob: json_remove would abort the statement on it, + // and one bad row must not cost every other row the fix. + var updated = context.Database.ExecuteSqlRaw( + """ + UPDATE "BaseItems" + SET "Data" = json_remove("Data", '$.LinkedChildren', '$.ExtraIds', '$.SupportsExternalTransfer') + WHERE "Data" IS NOT NULL + AND json_valid("Data") = 1 + AND ("Data" LIKE '%"LinkedChildren"%' + OR "Data" LIKE '%"ExtraIds"%' + OR "Data" LIKE '%"SupportsExternalTransfer"%') + """); + + _logger.LogInformation("Dropped dead keys from the serialized data of {Count} items", updated); + } +} diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs index 971b47608f..71706811b8 100644 --- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs +++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs @@ -4,8 +4,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Server.ServerSetupApp; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Stages; @@ -22,66 +20,45 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!; } - private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger) + public async Task Perform(IServiceProvider serviceProvider, IStartupLogger logger, CancellationToken cancellationToken) { - var childServiceCollection = new ServiceCollection() - .AddSingleton(serviceProvider) - .AddSingleton(logger) - .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>)) - .AddSingleton<StartupLogTopic>(logger.Topic!); + if (!IsMigrationRoutine(MigrationType)) + { + throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type"); + } - foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>()) + // The routine runs against a scope of the applications own container. Copying the application service + // descriptors into a child container instead would make that child container the owner of every singleton it + // forwards, so disposing it after the migration would also dispose the applications own instance of services + // like the ProviderManager and leave the server broken until the next restart. + var scope = serviceProvider.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) { - if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition) + // Nests everything the routine logs through an injected IStartupLogger under the migrations own topic. + using (StartupLogger.BeginAmbientTopic(logger.Topic)) { - childServiceCollection.AddSingleton(service.ServiceType, _ => serviceProvider.GetService(service.ServiceType)!); - continue; + await RunAsync(ActivatorUtilities.CreateInstance(scope.ServiceProvider, MigrationType), cancellationToken).ConfigureAwait(false); } - - childServiceCollection.Add(service); } - - return childServiceCollection; } - public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, CancellationToken cancellationToken) - { + // The obsolete IMigrationRoutine is still implemented by every routine that predates the async interface, so + // the members that have to touch it are grouped here behind a single suppression. #pragma warning disable CS0618 // Type or member is obsolete - if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType)) - { - if (serviceProvider is null) - { - ((IMigrationRoutine)Activator.CreateInstance(MigrationType)!).Perform(); - } - else - { - using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider(); - ((IMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).Perform(); -#pragma warning restore CS0618 // Type or member is obsolete - } - } - else if (typeof(IAsyncMigrationRoutine).IsAssignableFrom(MigrationType)) - { - if (serviceProvider is null) - { - await ((IAsyncMigrationRoutine)Activator.CreateInstance(MigrationType)!).PerformAsync(cancellationToken).ConfigureAwait(false); - } - else - { - using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider(); - await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false); - } - } - else - { - throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type"); - } + private static bool IsMigrationRoutine(Type migrationType) + { + return typeof(IMigrationRoutine).IsAssignableFrom(migrationType) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(migrationType); } - private class NestedStartupLogger<TCategory> : StartupLogger<TCategory> + private static async Task RunAsync(object routine, CancellationToken cancellationToken) { - public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic) + if (routine is IMigrationRoutine migrationRoutine) { + migrationRoutine.Perform(); + return; } + + await ((IAsyncMigrationRoutine)routine).PerformAsync(cancellationToken).ConfigureAwait(false); } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 12f92efb35..715fc3d3b9 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -61,6 +61,7 @@ namespace Jellyfin.Server private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; private static IStartupLogger<JellyfinMigrationService>? _migrationLogger; + private static bool _optimizeDatabaseAfterMigration; private static string? _restoreFromBackup; /// <summary> @@ -180,9 +181,7 @@ namespace Jellyfin.Server }) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) .UseSerilog() - .ConfigureServices(e => e - .RegisterStartupLogger() - .AddSingleton<IServiceCollection>(e)) + .ConfigureServices(e => e.RegisterStartupLogger()) .Build(); /* @@ -209,14 +208,15 @@ namespace Jellyfin.Server await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); // "Preparing migrations" carries through the DB read; per-migration progress is reported // as "Running migration X of Y" from inside the step once the pending set is known. - await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); SetupServer.ReportActivity(StartupActivity.InitializingServices); await appHost.InitializeServices(startupConfig).ConfigureAwait(false); _appHost = appHost; - await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false); await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false); + await OptimizeDatabaseAfterMigrationAsync(appHost.ServiceProvider).ConfigureAwait(false); try { configurationCompleted = true; @@ -271,12 +271,10 @@ namespace Jellyfin.Server // Don't throw additional exception if startup failed. if (appHost.ServiceProvider is not null) { - _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); + _logger.LogInformation("Preparing the database for shutdown..."); var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); - using var shutdownSource = new CancellationTokenSource(); - shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds); - await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false); + await databaseProvider.RunShutdownTask(CancellationToken.None).ConfigureAwait(false); } _appHost = null; @@ -307,14 +305,13 @@ namespace Jellyfin.Server .AddSingleton<ServerApplicationPaths>(appPaths) .RegisterStartupLogger(); - migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider); var startupService = migrationStartupServiceProvider.BuildServiceProvider(); PrepareDatabaseProvider(startupService); var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService); await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false); - await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false); } /// <summary> @@ -329,7 +326,32 @@ namespace Jellyfin.Server public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage) { var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider, _migrationLogger!); - await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false); + } + + private static async Task OptimizeDatabaseAfterMigrationAsync(IServiceProvider serviceProvider) + { + if (!_optimizeDatabaseAfterMigration) + { + return; + } + + // Reset first: a restart runs no migrations and must not optimize again. + _optimizeDatabaseAfterMigration = false; + SetupServer.ReportActivity(StartupActivity.OptimizingDatabase); + _logger.LogInformation("Migrations have been applied, optimizing the database... This might take a while"); + + try + { + // Deliberately untimed: incomplete statistics are worse than a slow start. + var databaseProvider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); + await databaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + // A missed optimization only costs performance, so never fail startup over this. + _logger.LogError(ex, "Error while optimizing the database after migration"); + } } /// <summary> diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs index 888cc617d4..abfc5bc8ba 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -27,6 +27,9 @@ public static class StartupActivity /// <summary>Bringing up core services and plugins.</summary> public const string InitializingServices = "Initializing services"; + /// <summary>Refreshing the database statistics after migrations have run.</summary> + public const string OptimizingDatabase = "Optimizing database"; + /// <summary>Running the final startup tasks.</summary> public const string FinishingStartup = "Finishing startup"; diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs index 0121854ce3..b72b0c0eab 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -8,6 +9,8 @@ namespace Jellyfin.Server.ServerSetupApp; /// <inheritdoc/> public class StartupLogger : IStartupLogger { + private static readonly AsyncLocal<StartupLogTopic?> _ambientTopic = new(); + private readonly StartupLogTopic? _topic; /// <summary> @@ -17,6 +20,7 @@ public class StartupLogger : IStartupLogger public StartupLogger(ILogger logger) { BaseLogger = logger; + _topic = _ambientTopic.Value; } /// <summary> @@ -39,6 +43,18 @@ public class StartupLogger : IStartupLogger /// </summary> protected ILogger BaseLogger { get; set; } + /// <summary> + /// Makes <paramref name="topic"/> the topic that loggers created on this execution context attach to. + /// </summary> + /// <param name="topic">The topic to nest newly created loggers under.</param> + /// <returns>A scope that restores the previously ambient topic when disposed.</returns> + internal static IDisposable BeginAmbientTopic(StartupLogTopic? topic) + { + var scope = new AmbientTopicScope(_ambientTopic.Value); + _ambientTopic.Value = topic; + return scope; + } + /// <inheritdoc/> public IStartupLogger BeginGroup(FormattableString logEntry) { @@ -121,4 +137,19 @@ public class StartupLogger : IStartupLogger Topic.Children.Add(startupEntry); } } + + private sealed class AmbientTopicScope : IDisposable + { + private readonly StartupLogTopic? _previous; + + public AmbientTopicScope(StartupLogTopic? previous) + { + _previous = previous; + } + + public void Dispose() + { + _ambientTopic.Value = _previous; + } + } } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 1802440dc4..560a419945 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -18,6 +18,7 @@ using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; using Jellyfin.Server.Implementations.Extensions; +using Jellyfin.Server.Implementations.Users; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; @@ -155,6 +156,7 @@ namespace Jellyfin.Server services.AddHostedService<LibraryChangedNotifier>(); services.AddHostedService<UserDataChangeNotifier>(); services.AddHostedService<RecordingNotifier>(); + services.AddHostedService<DeviceAccessHost>(); } /// <summary> diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index de07c7f2cb..1480a50c3b 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Common</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index 052626355f..4259b7b65c 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -47,6 +47,20 @@ namespace MediaBrowser.Controller.Dto } /// <summary> + /// Gets options that populate nothing beyond the item's own stored columns. + /// </summary> + /// <remarks> + /// Each enabled field group is a collection the item query left-joins, so the rows returned are + /// the product of the item's provider, image and user data counts. Never use this for items that + /// get saved back: saving rewrites the owned rows from what the instance holds. + /// </remarks> + public static DtoOptions StoredColumnsOnly => new(false) + { + EnableImages = false, + EnableUserData = false + }; + + /// <summary> /// Gets or sets the fields to populate on the DTO. /// </summary> public IReadOnlyList<ItemFields> Fields { get; set; } diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs index a02802f41e..ef24f632ed 100644 --- a/MediaBrowser.Controller/Entities/AggregateFolder.cs +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -54,6 +54,24 @@ namespace MediaBrowser.Controller.Entities public string[] PhysicalLocationsList { get; set; } + // Children caches the resolved items, _childrenIds the ids they were loaded from. Clearing + // only the former sends the next read back through LoadChildren, which replays the stale + // id list, so a caller invalidating this folder has to drop both. + [JsonIgnore] + public override IEnumerable<BaseItem> Children + { + get => base.Children; + set + { + if (value is null) + { + ClearCache(); + } + + base.Children = value; + } + } + public override bool CanDelete() { return false; diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index d016d8f62b..281a98dad5 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -165,6 +165,18 @@ namespace MediaBrowser.Controller.Entities.Audio public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { + try + { + await RefreshAllMetadataInternal(refreshOptions, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + ReleaseCachedChildren(); + } + } + + private async Task RefreshAllMetadataInternal(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) + { var items = GetRecursiveChildren(); var totalItems = items.Count; diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index d030c8f420..70e7da8932 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1411,7 +1411,8 @@ namespace MediaBrowser.Controller.Entities /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is /// kept in the label, falling back to a space only when no structural delimiter is shared. The /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming - /// VideoFlagDelimiters). + /// VideoFlagDelimiters), except that a dot between digits is a decimal point rather than a + /// delimiter, so numeric version labels stay whole. /// </summary> /// <param name="fileNames">The version file names without extension; must contain at least one entry.</param> /// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns> @@ -1445,9 +1446,12 @@ namespace MediaBrowser.Controller.Entities if (!prefixIsWholeName) { - // Retreat to the last structural delimiter ('-', '_', '.'). + // Retreat to the last structural delimiter ('-', '_', '.'), skipping dots that are + // decimal points within a number rather than delimiters (see IsDecimalPoint). var cut = prefix.Length; - while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0) + while (cut > 0 + && (Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0 + || IsDecimalPoint(prefix, cut - 1, fileNames))) { cut--; } @@ -1467,6 +1471,31 @@ namespace MediaBrowser.Controller.Entities return prefix; } + private static bool IsDecimalPoint(string prefix, int index, IReadOnlyList<string> fileNames) + { + if (index == 0 || prefix[index] != '.' || !char.IsDigit(prefix[index - 1])) + { + return false; + } + + if (index + 1 < prefix.Length) + { + return char.IsDigit(prefix[index + 1]); + } + + // The dot ends the prefix, so the character after it is the first one that differs between + // the versions: only a decimal point when every version continues the number. + for (var i = 0; i < fileNames.Count; i++) + { + if (fileNames[i].Length <= index + 1 || !char.IsDigit(fileNames[i][index + 1])) + { + return false; + } + } + + return true; + } + public Task RefreshMetadata(CancellationToken cancellationToken) { return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken); diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index d8203ea6f2..626bc0d5a1 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -286,6 +286,27 @@ namespace MediaBrowser.Controller.Entities return GetCachedChildren(); } + /// <summary> + /// Drops the children this folder has materialised, and the ones held by every folder below + /// it, without loading anything that is not already in memory. + /// </summary> + public void ReleaseCachedChildren() + { + // Cleared before descending, so a folder already on the way down is not walked twice. + var children = _children; + _children = null; + + if (children is null) + { + return; + } + + foreach (var child in children) + { + (child as Folder)?.ReleaseCachedChildren(); + } + } + public override double? GetRefreshProgress() { return ProviderManager.GetRefreshProgress(Id); @@ -316,7 +337,7 @@ namespace MediaBrowser.Controller.Entities var dictionary = new Dictionary<Guid, BaseItem>(); Children = null; // invalidate cached children. - var childrenList = Children.ToList(); + var childrenList = GetChildrenForValidation(); foreach (var child in childrenList) { @@ -370,6 +391,9 @@ namespace MediaBrowser.Controller.Entities { ProviderManager.OnRefreshComplete(this); } + + // The subtree is done with, so stop holding it. + ReleaseCachedChildren(); } } @@ -405,19 +429,23 @@ namespace MediaBrowser.Controller.Entities if (IsFileProtocol) { - IEnumerable<BaseItem> nonCachedChildren = []; + IEnumerable<BaseItem> nonCachedChildren; try { - nonCachedChildren = GetNonCachedChildren(directoryService); + // Finish enumeration before mutating the library. An I/O failure, including + // one partway through a lazy enumeration, must not look like removed files. + nonCachedChildren = GetNonCachedChildren(directoryService).ToArray(); } catch (IOException ex) { Logger.LogError(ex, "Error retrieving children from file system"); + return; } catch (SecurityException ex) { Logger.LogError(ex, "Error retrieving children from file system"); + return; } catch (Exception ex) { @@ -551,7 +579,7 @@ namespace MediaBrowser.Controller.Entities && primaryVideo.OwnerId.IsEmpty() && (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p))) { - var newPrimary = newItems + var newPrimary = validChildren .OfType<Video>() .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) .Any(p => (primaryVideo.LocalAlternateVersions ?? []) @@ -593,6 +621,8 @@ namespace MediaBrowser.Controller.Entities newPrimary.Name, newPrimary.Id); + await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false); + // Reroute collection/playlist references from old primary to new primary await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false); @@ -621,9 +651,12 @@ namespace MediaBrowser.Controller.Entities LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false); } - // Demote old primaries that are now alternate versions of newly created primaries. + // Demote old primaries that are now alternate versions of another primary. // This handles the case where a new file is added that becomes the new primary - // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion). + // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion), and the case + // where the file that takes over was already in the library and merely traded + // places with this one — so the new primary is looked up among all valid children + // rather than only the newly created ones. // Items in replacedPrimaries are excluded (already in actuallyRemoved). var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>(); foreach (var item in itemsRemoved.Except(actuallyRemoved)) @@ -633,7 +666,7 @@ namespace MediaBrowser.Controller.Entities && !string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path)) { - var newPrimary = newItems + var newPrimary = validChildren .OfType<Video>() .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) .Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase))); @@ -653,10 +686,13 @@ namespace MediaBrowser.Controller.Entities newPrimary.Name, newPrimary.Id); + await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false); + // First: update old primary's alternate items to point to new primary. // Order matters — update alternates FIRST so they don't get orphan-deleted // when old primary's arrays are cleared. - var oldAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary) + var oldLocalAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet(); + var oldAlternateIds = oldLocalAlternateIds .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id)) .Distinct() .ToList(); @@ -666,7 +702,10 @@ namespace MediaBrowser.Controller.Entities if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id)) { altVideo.SetPrimaryVersionId(newPrimary.Id); - altVideo.OwnerId = newPrimary.Id; + + // Only a version stored next to the new primary is owned by it; one that + // was merged in by hand keeps its own row and must stay unowned. + altVideo.OwnerId = oldLocalAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty; await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); } } @@ -772,6 +811,23 @@ namespace MediaBrowser.Controller.Entities } } + private async Task PromoteToPrimaryVersionAsync(Video newPrimary, CancellationToken cancellationToken) + { + if (!newPrimary.PrimaryVersionId.HasValue && newPrimary.OwnerId.IsEmpty()) + { + return; + } + + Logger.LogInformation( + "Promoting {Name} ({Id}) to the primary version of its group", + newPrimary.Name, + newPrimary.Id); + + newPrimary.SetPrimaryVersionId(null); + newPrimary.OwnerId = Guid.Empty; + await newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + private async Task RefreshMetadataRecursive(IList<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken) { await RunTasks( @@ -807,7 +863,14 @@ namespace MediaBrowser.Controller.Entities if (recursive && child is Folder folder) { folder.Children = null; // invalidate cached children. - await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); + try + { + await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + folder.ReleaseCachedChildren(); + } } } } @@ -876,6 +939,17 @@ namespace MediaBrowser.Controller.Entities }); } + private IReadOnlyList<BaseItem> GetChildrenForValidation() + { + return ItemRepository.GetItemList(new InternalItemsQuery + { + Parent = this, + GroupByPresentationUniqueKey = false, + IncludeAlternateVersions = true, + DtoOptions = new DtoOptions(true) + }); + } + public virtual int GetChildCount(User user) { if (LinkedChildren.Length > 0) @@ -1613,7 +1687,17 @@ namespace MediaBrowser.Controller.Entities /// <returns>IEnumerable{BaseItem}.</returns> public List<BaseItem> GetLinkedChildren() { - var resolved = ResolveLinkedChildren(LinkedChildren); + return GetLinkedChildren(new DtoOptions()); + } + + /// <summary> + /// Gets the linked children, populating only what <paramref name="options"/> asks for. + /// </summary> + /// <param name="options">Fields to populate on the resolved children.</param> + /// <returns>The resolved children.</returns> + public List<BaseItem> GetLinkedChildren(DtoOptions options) + { + var resolved = ResolveLinkedChildren(LinkedChildren, options); var list = new List<BaseItem>(resolved.Count); foreach (var (_, item) in resolved) { @@ -1730,8 +1814,9 @@ namespace MediaBrowser.Controller.Entities /// path (legacy path-based resolution). /// </summary> /// <param name="linkedChildren">Linked children to resolve.</param> + /// <param name="options">Fields to populate on the resolved items; all fields when null.</param> /// <returns>Each input entry paired with its resolved item; entries that fail to resolve are dropped.</returns> - private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren) + private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren, DtoOptions options = null) { var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count); if (linkedChildren.Count == 0) @@ -1753,7 +1838,8 @@ namespace MediaBrowser.Controller.Entities { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToBatch] + ItemIds = [.. idsToBatch], + DtoOptions = options ?? new DtoOptions() }); byId = new Dictionary<Guid, BaseItem>(batched.Count); foreach (var item in batched) diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 0e5a5047cd..7c88d5dd05 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -432,12 +432,18 @@ namespace MediaBrowser.Controller.Entities public string? HasNoSubtitleTrackWithLanguage { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to return only items nothing names any more. + /// </summary> public bool? IsDeadArtist { get; set; } public bool? IsDeadStudio { get; set; } public bool? IsDeadGenre { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to return only items nothing names any more. + /// </summary> public bool? IsDeadPerson { get; set; } /// <summary> @@ -482,6 +488,14 @@ namespace MediaBrowser.Controller.Entities /// </summary> public bool IncludeOwnedItems { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to include alternate versions, which carry a + /// <see cref="Video.PrimaryVersionId"/> and are normally hidden behind the version they + /// belong to. Unlike <see cref="IncludeOwnedItems"/> this keeps the versions a user merged + /// by hand without also returning the parts and extras owned by another item. + /// </summary> + public bool IncludeAlternateVersions { get; set; } + public bool? Is4K { get; set; } public int? MaxHeight { get; set; } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 8216937cad..16d8bfe391 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -11,6 +11,7 @@ using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Querying; @@ -89,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies return base.GetNonCachedChildren(directoryService); } - return Enumerable.Empty<BaseItem>(); + return []; } protected override IReadOnlyList<BaseItem> LoadChildren() @@ -168,14 +169,21 @@ namespace MediaBrowser.Controller.Entities.Movies return true; } - var userLibraryFolderIds = GetLibraryFolderIds(user); - var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds(); + List<BaseItem> linkedItems = null; + var libraryFolderIds = LibraryFolderIds; + if (libraryFolderIds is null) + { + linkedItems = GetLinkedChildren(DtoOptions.StoredColumnsOnly); + libraryFolderIds = GetLibraryFolderIds(linkedItems); + } if (libraryFolderIds.Length == 0) { return true; } + var userLibraryFolderIds = GetLibraryFolderIds(user); + if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i))) { return false; @@ -184,7 +192,7 @@ namespace MediaBrowser.Controller.Entities.Movies // If user has parental controls, hide the BoxSet when all children are restricted if (user.MaxParentalRatingScore.HasValue) { - var linkedItems = GetLinkedChildren(); + linkedItems ??= GetLinkedChildren(DtoOptions.StoredColumnsOnly); if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true))) { return false; @@ -241,10 +249,19 @@ namespace MediaBrowser.Controller.Entities.Movies public Guid[] GetLibraryFolderIds() { - var expandedFolders = new List<Guid>(); + return GetLibraryFolderIds(GetLinkedChildren(DtoOptions.StoredColumnsOnly)); + } + + private Guid[] GetLibraryFolderIds(IEnumerable<BaseItem> linkedChildren) + { + // Seeded with this box set so a cycle through a nested collection terminates. + var expandedFolders = new List<Guid> { Id }; + + // The user root children are the same for every item. + var rootChildren = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); - return FlattenItems(this, expandedFolders) - .SelectMany(LibraryManager.GetCollectionFolders) + return FlattenItems(linkedChildren, expandedFolders) + .SelectMany(i => LibraryManager.GetCollectionFolders(i, rootChildren)) .Select(i => i.Id) .Distinct() .ToArray(); @@ -264,13 +281,13 @@ namespace MediaBrowser.Controller.Entities.Movies { expandedFolders.Add(item.Id); - return FlattenItems(boxset.GetLinkedChildren(), expandedFolders); + return FlattenItems(boxset.GetLinkedChildren(DtoOptions.StoredColumnsOnly), expandedFolders); } - return Array.Empty<BaseItem>(); + return []; } - return new[] { item }; + return [item]; } } } diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 1a1da84b7a..126f4361ba 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -333,6 +333,19 @@ namespace MediaBrowser.Controller.Entities.TV public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { Children = null; // invalidate cached children. + + try + { + await RefreshAllMetadataInternal(refreshOptions, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + ReleaseCachedChildren(); + } + } + + private async Task RefreshAllMetadataInternal(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) + { // Refresh bottom up, seasons and episodes first, then the series var items = GetRecursiveChildren(); diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index f9ad2d86e6..82256cd964 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -455,25 +455,34 @@ namespace MediaBrowser.Controller.Entities { var itemList = filtered.ToList(); var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList(); + var leaves = itemList.Where(i => i is not Folder).ToList(); + var isPlayedValue = query.IsPlayed.Value; - if (folderIds.Count > 0) - { - var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); - var isPlayedValue = query.IsPlayed.Value; + var counts = folderIds.Count > 0 + ? libraryManager.GetPlayedAndTotalCountBatch(folderIds, user) + : null; + + // A movie held as several files is watched once any of its versions is watched. + var resumeData = leaves.Count > 0 + ? userDataManager.GetResumeUserDataBatch(leaves, user) + : null; - return itemList.Where(item => + return itemList.Where(item => + { + if (item is Folder) { - if (item is Folder) - { - var itemCount = counts.GetValueOrDefault(item.Id); - return (itemCount.Played >= itemCount.Total) == isPlayedValue; - } + var itemCount = counts?.GetValueOrDefault(item.Id) ?? default; + return (itemCount.Played >= itemCount.Total) == isPlayedValue; + } - return true; - }); - } + var played = userDataManager.GetUserData(user, item)?.Played ?? false; + if (!played && resumeData is not null && resumeData.TryGetValue(item.Id, out var versionData)) + { + played = versionData.UserData.Played; + } - return itemList; + return played == isPlayedValue; + }); } return filtered; @@ -606,19 +615,7 @@ namespace MediaBrowser.Controller.Entities } } - if (query.IsPlayed.HasValue) - { - // Folder.IsPlayed() hits the DB per-item (N+1 queries). - // Folders are batch-filtered by the collection Filter() overload. - if (!item.IsFolder) - { - userData ??= userDataManager.GetUserData(user, item); - if (item.IsPlayed(user, userData) != query.IsPlayed.Value) - { - return false; - } - } - } + // IsPlayed is answered by the collection Filter() overload for folders and leaves alike. if (query.IsLocked.HasValue) { diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index c8cca1fa93..82de3546f0 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -107,6 +107,13 @@ namespace MediaBrowser.Controller.Library Person? GetPerson(string name); /// <summary> + /// Gets a Person, creating and persisting it if no item exists for the name yet. + /// </summary> + /// <param name="name">The name of the person.</param> + /// <returns>The person.</returns> + Person GetOrCreatePerson(string name); + + /// <summary> /// Finds the by path. /// </summary> /// <param name="path">The path.</param> @@ -153,15 +160,6 @@ namespace MediaBrowser.Controller.Library Year GetYear(int value); /// <summary> - /// Validate and refresh the People sub-set of the IBN. - /// The items are stored in the db but not loaded into memory until actually requested by an operation. - /// </summary> - /// <param name="progress">The progress.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken); - - /// <summary> /// Reloads the root media folder. /// </summary> /// <param name="progress">The progress.</param> @@ -709,6 +707,14 @@ namespace MediaBrowser.Controller.Library /// <returns><c>true</c> if ignored, <c>false</c> otherwise.</returns> bool IgnoreFile(FileSystemMetadata file, BaseItem parent); + /// <summary> + /// Gets the id a <see cref="Person"/> item for the name would have, without looking it up + /// or creating it. + /// </summary> + /// <param name="name">The name of the person.</param> + /// <returns>The item id for the name.</returns> + Guid GetPersonId(string name); + Guid GetStudioId(string name); Guid GetGenreId(string name); @@ -754,6 +760,18 @@ namespace MediaBrowser.Controller.Library ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user); /// <summary> + /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned + /// item value - artists, genres and studios - are answered in one set of queries for the + /// whole batch; the rest fall back to one query per item. + /// </summary> + /// <param name="kind">The kind of the name items.</param> + /// <param name="ids">The IDs of the name items.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="user">The user for access filtering.</param> + /// <returns>The item counts of each requested id.</returns> + Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user); + + /// <summary> /// Batch-fetches child counts for multiple parent folders. /// Returns the count of immediate children (non-recursive) for each parent. /// </summary> @@ -815,6 +833,13 @@ namespace MediaBrowser.Controller.Library QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); /// <summary> + /// Gets a list of all distinct tags of the matching items. + /// </summary> + /// <param name="query">The query filter.</param> + /// <returns>List of tags.</returns> + IReadOnlyList<string> GetTagNames(InternalItemsQuery query); + + /// <summary> /// Gets a list of all language codes of the provided stream type. /// </summary> /// <param name="mediaStreamType">The stream type.</param> diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs index 6da398129a..be75117b6f 100644 --- a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs +++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs @@ -17,7 +17,8 @@ namespace MediaBrowser.Controller.LibraryTaskScheduler; /// </summary> public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable { - private const int CleanupGracePeriod = 60; + private static readonly TimeSpan _cleanupGracePeriod = TimeSpan.FromSeconds(60); + private readonly IHostApplicationLifetime _hostApplicationLifetime; private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger; private readonly IServerConfigurationManager _serverConfigurationManager; @@ -31,6 +32,8 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr private readonly Lock _taskLock = new(); private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>(); + private readonly CancellationTokenSource _disposeTokenSource = new(); + private readonly TimeSpan _gracePeriod; private volatile int _workCounter; private Task? _cleanupTask; @@ -46,10 +49,34 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr IHostApplicationLifetime hostApplicationLifetime, ILogger<LimitedConcurrencyLibraryScheduler> logger, IServerConfigurationManager serverConfigurationManager) + : this(hostApplicationLifetime, logger, serverConfigurationManager, _cleanupGracePeriod) + { + } + + internal LimitedConcurrencyLibraryScheduler( + IHostApplicationLifetime hostApplicationLifetime, + ILogger<LimitedConcurrencyLibraryScheduler> logger, + IServerConfigurationManager serverConfigurationManager, + TimeSpan gracePeriod) { _hostApplicationLifetime = hostApplicationLifetime; _logger = logger; _serverConfigurationManager = serverConfigurationManager; + _gracePeriod = gracePeriod; + } + + /// <summary> + /// Gets the number of runners the scheduler currently keeps alive. + /// </summary> + internal int ActiveRunnerCount + { + get + { + lock (_taskLock) + { + return _taskRunners.Count; + } + } } private void ScheduleTaskCleanup() @@ -68,31 +95,65 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr async Task RunCleanupTask() { - _logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod); - await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false); - if (_disposed) + while (true) { - _logger.LogDebug("Abort cleaning up, already disposed."); - return; - } + _logger.LogDebug("Schedule cleanup task in {CleanupGracePeriod}.", _gracePeriod); + try + { + await Task.Delay(_gracePeriod, _disposeTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Abort cleaning up, already disposed."); + return; + } - lock (_taskLock) - { - if (_tasks.Reader.Count > 0 || _workCounter > 0) + if (_disposed) { - _logger.LogDebug("Delay cleanup task, operations still running."); - // tasks are still there so its still in use. Reschedule cleanup task. - // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended. - _cleanupTask = RunCleanupTask(); + _logger.LogDebug("Abort cleaning up, already disposed."); return; } + + CancellationTokenSource[] runners; + lock (_taskLock) + { + if (_tasks.Reader.Count > 0 || _workCounter > 0) + { + _logger.LogDebug("Delay cleanup task, operations still running."); + // tasks are still there so its still in use. Wait another grace period. + // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended. + continue; + } + + runners = [.. _taskRunners.Keys]; + + // Retire the runners before they are told to stop: an operation starting while + // they wind down must spawn its own instead of counting these towards the fanout. + _taskRunners.Clear(); + + // Hand the next operation the ability to schedule a cleanup again. Without this + // the very first cleanup would be the only one that ever runs. + _cleanupTask = null; + } + + _logger.LogDebug("Cleanup runners."); + await StopRunners(runners).ConfigureAwait(false); + return; } + } + } - _logger.LogDebug("Cleanup runners."); - foreach (var item in _taskRunners.ToArray()) + private static async Task StopRunners(CancellationTokenSource[] runners) + { + foreach (var runner in runners) + { + try { - await item.Key.CancelAsync().ConfigureAwait(false); - _taskRunners.Remove(item.Key); + await runner.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // The runner already stopped on its own and disposed its stop source. } } } @@ -127,12 +188,17 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr { var stopToken = new CancellationTokenSource(); var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping); + + // Keyed on its own stop source, because cancelling that is what reaches the linked + // source the runner waits on. Cancellation does not travel the other way. + // Started without the runner's own token: a task cancelled before it is scheduled + // never runs its body, so it would never take itself out of _taskRunners again. _taskRunners.Add( - combinedSource, + stopToken, Task.Factory.StartNew( ItemWorker, - (combinedSource, stopToken), - combinedSource.Token, + (stopToken, combinedSource), + CancellationToken.None, TaskCreationOptions.PreferFairness, TaskScheduler.Default)); } @@ -145,7 +211,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr _deadlockDetector.Value = stopToken.TaskStop; try { - while (!stopToken.GlobalStop.Token.IsCancellationRequested) + while (!stopToken.GlobalStop.IsCancellationRequested) { var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false); try @@ -162,15 +228,24 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr } } } - catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested) + catch (OperationCanceledException) when (stopToken.GlobalStop.IsCancellationRequested) { // thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose. } + catch (ChannelClosedException) + { + // the scheduler was disposed and will not hand out any more work. + } finally { _logger.LogDebug("Cleanup Runner'."); _deadlockDetector.Value = default!; - _taskRunners.Remove(stopToken.TaskStop); + + lock (_taskLock) + { + _taskRunners.Remove(stopToken.TaskStop); + } + stopToken.GlobalStop.Dispose(); stopToken.TaskStop.Dispose(); } @@ -195,7 +270,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr finally { item.Progress.Report(100); - item.Done.SetResult(); + item.Done.TrySetResult(); } } @@ -285,16 +360,33 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr _disposed = true; _tasks.Writer.Complete(); - foreach (var item in _taskRunners) + + // Nobody is left to run these, so release whoever is waiting on them. + while (_tasks.Reader.TryRead(out var item)) { - await item.Key.CancelAsync().ConfigureAwait(false); + item.Done.TrySetResult(); } - if (_cleanupTask is not null) + CancellationTokenSource[] runners; + Task? cleanupTask; + lock (_taskLock) { - await _cleanupTask.ConfigureAwait(false); - _cleanupTask?.Dispose(); + runners = [.. _taskRunners.Keys]; + _taskRunners.Clear(); + cleanupTask = _cleanupTask; } + + await StopRunners(runners).ConfigureAwait(false); + + // Cuts the grace period short instead of holding up shutdown for the rest of it. + await _disposeTokenSource.CancelAsync().ConfigureAwait(false); + + if (cleanupTask is not null) + { + await cleanupTask.ConfigureAwait(false); + } + + _disposeTokenSource.Dispose(); } private class TaskQueueItem diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 8d59eef9f1..77e0087048 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -201,6 +201,13 @@ namespace MediaBrowser.Controller.LiveTv IEnumerable<User> GetEnabledUsers(); /// <summary> + /// Gets whether Live TV is enabled for a single user. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>Whether Live TV is enabled for the user.</returns> + bool IsEnabledForUser(User user); + + /// <summary> /// Gets the internal channels. /// </summary> /// <param name="query">The query.</param> diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 06188ad511..da2ffdeb79 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Controller</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> @@ -18,7 +18,6 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="BitFaster.Caching" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> </ItemGroup> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 10c21ee03c..6f010c0242 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -442,7 +442,8 @@ namespace MediaBrowser.Controller.MediaEncoding && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 || IsHdr10Plus(state.VideoStream) || IsDoviWithHdr10Bl(state.VideoStream) - || state.VideoStream.VideoRangeType == VideoRangeType.HLG); + || state.VideoStream.VideoRangeType == VideoRangeType.HLG + || state.VideoStream.VideoRangeType == VideoRangeType.DOVIInvalid); } private static bool IsDeinterlaceAvailable(EncodingJobInfo state) @@ -695,7 +696,11 @@ namespace MediaBrowser.Controller.MediaEncoding "ogg" or "oga" or "ogv" or "webm" or "webma" => "opus", "m4a" or "m4b" or "mp4" or "mov" or "mkv" or "mka" => "aac", "ts" or "avi" or "flv" or "f4v" or "swf" => "mp3", - _ => inferredCodec + // Containers that share their name with the codec they carry. + "aac" or "ac3" or "alac" or "dts" or "eac3" or "flac" or "mp2" or "mp3" or "opus" or "truehd" or "vorbis" => inferredCodec, + // Anything else - manifests such as m3u8/mpd in particular - names a container that + // is not an audio codec. Never hand that name to ffmpeg as an encoder. + _ => "aac" }; } @@ -1386,7 +1391,8 @@ namespace MediaBrowser.Controller.MediaEncoding or VideoRangeType.DOVIWithEL or VideoRangeType.DOVIWithHDR10Plus or VideoRangeType.DOVIWithELHDR10Plus - or VideoRangeType.DOVIInvalid; + || (rangeType == VideoRangeType.DOVIInvalid + && string.Equals(stream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)); // invalid may be hlg now } public static bool IsDovi(MediaStream stream) @@ -1396,7 +1402,8 @@ namespace MediaBrowser.Controller.MediaEncoding return IsDoviWithHdr10Bl(stream) || (rangeType is VideoRangeType.DOVI or VideoRangeType.DOVIWithHLG - or VideoRangeType.DOVIWithSDR); + or VideoRangeType.DOVIWithSDR + or VideoRangeType.DOVIInvalid); } public static bool IsHdr10Plus(MediaStream stream) @@ -1416,7 +1423,8 @@ namespace MediaBrowser.Controller.MediaEncoding private static DynamicHdrMetadataRemovalPlan ShouldRemoveDynamicHdrMetadata(EncodingJobInfo state) { var videoStream = state.VideoStream; - if (videoStream.VideoRange is not VideoRange.HDR) + if (videoStream.VideoRange is not VideoRange.HDR + && videoStream.VideoRangeType != VideoRangeType.DOVIInvalid) { return DynamicHdrMetadataRemovalPlan.None; } @@ -3799,6 +3807,11 @@ namespace MediaBrowser.Controller.MediaEncoding var formatArg = isFormatFixed ? (":format=" + videoFormat) : string.Empty; var tonemapArg = string.Empty; + // libplacebo only support full range RGB + forceFullRange = forceFullRange + || (videoFormat ?? string.Empty).Contains("rgb", StringComparison.OrdinalIgnoreCase) + || (videoFormat ?? string.Empty).Contains("bgr", StringComparison.OrdinalIgnoreCase); + if (doTonemap) { var algorithm = options.TonemappingAlgorithm; @@ -3822,6 +3835,10 @@ namespace MediaBrowser.Controller.MediaEncoding tonemapArg += ":range=" + range.ToString().ToLowerInvariant(); } } + else if (forceFullRange) + { + formatArg += ":range=pc"; + } return string.Format( CultureInfo.InvariantCulture, @@ -5462,7 +5479,14 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add("format=vaapi"); // clear the surf->meta_offset and output nv12 - mainFilters.Add("scale_vaapi=format=nv12"); + var hwCscFilter = "scale_vaapi=format=nv12"; + + if (!isMjpegEncoder && options.TonemappingRange != TonemappingRange.pc) + { + hwCscFilter += ":out_range=tv"; + } + + mainFilters.Add(hwCscFilter); // hw deint if (doDeintH2645) @@ -5532,7 +5556,14 @@ namespace MediaBrowser.Controller.MediaEncoding overlayFilters.Add("format=vaapi"); // clear the surf->meta_offset and output nv12 - overlayFilters.Add("scale_vaapi=format=nv12"); + var hwCscFilter = "scale_vaapi=format=nv12"; + + if (!doVkTonemap || (doVkTonemap && options.TonemappingRange != TonemappingRange.pc)) + { + hwCscFilter += ":out_range=tv"; + } + + overlayFilters.Add(hwCscFilter); // hw deint if (doDeintH2645) @@ -6311,7 +6342,7 @@ namespace MediaBrowser.Controller.MediaEncoding string.Join(',', overlayFilters)); var mapPrefix = Convert.ToInt32(state.SubtitleStream.IsExternal); - var subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream); + var subtitleStreamIndex = GetSubtitleStreamIndexForFfmpeg(state.MediaSource, state.SubtitleStream); var videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream); if (hasSubs) @@ -7943,6 +7974,24 @@ namespace MediaBrowser.Controller.MediaEncoding return -1; } + public static int GetSubtitleStreamIndexForFfmpeg(MediaSourceInfo mediaSource, MediaStream subtitleStream) + { + var index = FindIndex(mediaSource.MediaStreams, subtitleStream); + if (index == -1 || subtitleStream.IsExternal || mediaSource.VideoType != VideoType.BluRay) + { + return index; + } + + var hiddenStreamsBefore = mediaSource.MediaStreams.Count(s => + s.Type == MediaStreamType.Audio + && !s.IsExternal + && (string.Equals(s.Codec, "truehd", StringComparison.OrdinalIgnoreCase) + || string.Equals(s.Codec, "atmos", StringComparison.OrdinalIgnoreCase)) + && s.Index < subtitleStream.Index); + + return index + hiddenStreamsBefore; + } + public static bool IsCopyCodec(string codec) { return string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase); diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs index ca55340a05..50ed76f762 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs @@ -4,7 +4,9 @@ using MediaBrowser.Model.Session; namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> -/// Force keep alive websocket messages. +/// Force keep alive websocket messages. The data is the timeout in seconds after which the +/// server considers the connection lost; clients are expected to answer with a KeepAlive +/// message and to keep sending one at least every half of that timeout. /// </summary> public class ForceKeepAliveMessage : OutboundWebSocketMessage<int> { diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs index 8ddf93e3e0..14c422cb60 100644 --- a/MediaBrowser.Controller/Persistence/IItemCountService.cs +++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs @@ -37,6 +37,18 @@ public interface IItemCountService ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter); /// <summary> + /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned + /// item value - artists, genres and studios - are answered in one set of queries for the whole + /// batch; the rest fall back to one query per id. + /// </summary> + /// <param name="kind">The kind of the name items.</param> + /// <param name="ids">The IDs of the name items.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="accessFilter">A pre-configured query with user access filtering settings.</param> + /// <returns>The item counts of each requested id.</returns> + Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter); + + /// <summary> /// Gets the count of played items that are descendants of the specified ancestor. /// </summary> /// <param name="filter">The query filter containing user access settings.</param> diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index d44fe57bed..3cf06b897c 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -129,6 +129,13 @@ public interface IItemRepository public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType); /// <summary> + /// Gets all distinct tags of the matching base items. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The list of tags.</returns> + IReadOnlyList<string> GetTagNames(InternalItemsQuery filter); + + /// <summary> /// Gets all artist names. /// </summary> /// <returns>The list of artist names.</returns> diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index fc367b8293..edf3fb25c9 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -235,18 +235,18 @@ namespace MediaBrowser.Controller.Playlists { if (!IsSharedItem) { - return base.IsVisible(user, skipAllowedTagsCheck); + return base.IsVisible(user, skipAllowedTagsCheck) && HasParentalAllowedChild(user); } if (OpenAccess) { - return true; + return HasParentalAllowedChild(user); } var userId = user.Id; if (userId.Equals(OwnerUserId)) { - return true; + return HasParentalAllowedChild(user); } var shares = Shares; @@ -255,7 +255,19 @@ namespace MediaBrowser.Controller.Playlists return false; } - return shares.Any(s => s.UserId.Equals(userId)); + return shares.Any(s => s.UserId.Equals(userId)) && HasParentalAllowedChild(user); + } + + private bool HasParentalAllowedChild(User user) + { + if (!user.MaxParentalRatingScore.HasValue) + { + return true; + } + + var linkedItems = GetLinkedChildren(); + + return linkedItems.Count == 0 || linkedItems.Any(child => child.IsParentalAllowed(user, true)); } public override bool CanDelete(User user) diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index 6060d051a5..f8e0bf4ed9 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -5,13 +5,19 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using MediaBrowser.Model.IO; namespace MediaBrowser.Controller.Providers { public class DirectoryService : IDirectoryService { - // TODO make static and switch to FastConcurrentLru. + // TODO replace with one shared bounded cache. + private const int MaxCachedRecords = 100_000; + private const int AccessIntervalMs = 1_000; + // Timeout cache if no access for 5 minutes. + private const int IdleTimeoutMs = 5 * 60 * 1_000; + private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new(StringComparer.Ordinal); @@ -20,6 +26,12 @@ namespace MediaBrowser.Controller.Providers private readonly IFileSystem _fileSystem; + // ConcurrentDictionary.Count locks the dictionary, so keep an estimated counter. + // Concurrent factory runs can overcount and a clear racing an add can undercount, + // it only has to be roughly right. + private int _recordCount; + private long _lastAccess = Environment.TickCount64; + public DirectoryService(IFileSystem fileSystem) { _fileSystem = fileSystem; @@ -27,20 +39,26 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata[] GetFileSystemEntries(string path) { + DropCacheIfIdleOrFull(); + return _cache.GetOrAdd( path, - static (p, fileSystem) => + static (p, state) => { + FileSystemMetadata[] entries; try { - return fileSystem.GetFileSystemEntries(p).ToArray(); + entries = state.FileSystem.GetFileSystemEntries(p).ToArray(); } catch (DirectoryNotFoundException) { - return []; + entries = []; } + + Interlocked.Add(ref state.Service._recordCount, entries.Length + 1); + return entries; }, - _fileSystem); + (FileSystem: _fileSystem, Service: this)); } public List<FileSystemMetadata> GetDirectories(string path) @@ -89,13 +107,18 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata? GetFileSystemEntry(string path) { + DropCacheIfIdleOrFull(); + if (!_fileCache.TryGetValue(path, out var result)) { var file = _fileSystem.GetFileSystemInfo(path); if (file?.Exists ?? false) { result = file; - _fileCache.TryAdd(path, result); + if (_fileCache.TryAdd(path, result)) + { + Interlocked.Increment(ref _recordCount); + } } } @@ -107,32 +130,96 @@ namespace MediaBrowser.Controller.Providers public IReadOnlyList<string> GetFilePaths(string path, bool clearCache) { - if (clearCache) + if (clearCache && _filePathCache.TryRemove(path, out var cached)) { - _filePathCache.TryRemove(path, out _); + Interlocked.Add(ref _recordCount, -(cached.Count + 1)); } + DropCacheIfIdleOrFull(); + var filePaths = _filePathCache.GetOrAdd( path, - static (p, fileSystem) => + static (p, state) => { + List<string> filePaths; try { - return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); + filePaths = state.FileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); } catch (DirectoryNotFoundException) { - return []; + filePaths = []; } + + Interlocked.Add(ref state.Service._recordCount, filePaths.Count + 1); + return filePaths; }, - _fileSystem); + (FileSystem: _fileSystem, Service: this)); return filePaths; } + public void Invalidate(string path) + { + Forget(path); + + var parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + { + Forget(parent); + } + } + + public void Move(string source, string destination) + { + Directory.Move(source, destination); + + Invalidate(source); + Invalidate(destination); + } + public bool IsAccessible(string path) { return _fileSystem.GetFileSystemEntryPaths(path).Any(); } + + private void DropCacheIfIdleOrFull() + { + var nowMs = Environment.TickCount64; + var idleMs = nowMs - _lastAccess; + + if (idleMs >= IdleTimeoutMs || _recordCount >= MaxCachedRecords) + { + _cache.Clear(); + _fileCache.Clear(); + _filePathCache.Clear(); + _recordCount = 0; + _lastAccess = nowMs; + return; + } + + if (idleMs >= AccessIntervalMs) + { + _lastAccess = nowMs; + } + } + + private void Forget(string path) + { + if (_cache.TryRemove(path, out var entries)) + { + Interlocked.Add(ref _recordCount, -(entries.Length + 1)); + } + + if (_fileCache.TryRemove(path, out _)) + { + Interlocked.Decrement(ref _recordCount); + } + + if (_filePathCache.TryRemove(path, out var filePaths)) + { + Interlocked.Add(ref _recordCount, -(filePaths.Count + 1)); + } + } } } diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index 8a3fa33da3..3a943d5f0c 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -23,6 +23,19 @@ namespace MediaBrowser.Controller.Providers IReadOnlyList<string> GetFilePaths(string path, bool clearCache); + /// <summary> + /// Forgets what is cached about a path and about the directory containing it. + /// </summary> + /// <param name="path">The file or directory path that changed.</param> + void Invalidate(string path); + + /// <summary> + /// Moves a directory and forgets what is cached about both paths. + /// </summary> + /// <param name="source">The directory to move.</param> + /// <param name="destination">The path to move the directory to.</param> + void Move(string source, string destination); + bool IsAccessible(string path); } } diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs index eb38eeb503..8f17039ae1 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -50,6 +50,11 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates /// </summary> private GroupStateType InitialState { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the group position moved during this wait. + /// </summary> + private bool PositionJumped { get; set; } + /// <inheritdoc /> public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) { @@ -136,6 +141,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates ResumePlaying = true; var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks); + PositionJumped = setQueueStatus; if (!setQueueStatus) { _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString()); @@ -175,6 +181,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates ResumePlaying = true; var result = context.SetPlayingItem(request.PlaylistItemId); + PositionJumped = result; if (result) { var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); @@ -214,6 +221,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { ResumePlaying = true; context.RestartCurrentItem(); + PositionJumped = true; var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); @@ -310,6 +318,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates // Seek. context.PositionTicks = ticks; context.LastActivity = DateTime.UtcNow; + PositionJumped = true; var command = context.NewSyncPlayCommand(SendCommandType.Seek); context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); @@ -450,7 +459,13 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Handle case where session reported as ready but in reality // it has no clue of the real position nor the playback state. - if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks) + // A jump means the session has not applied the new position; without one it is + // catching up after buffering and is allowed to lag. + var maxOffsetTicks = request.IsPlaying && !PositionJumped + ? TimeSpan.FromMilliseconds(context.MaxCatchUpOffset).Ticks + : maxPlaybackOffsetTicks; + + if (Math.Abs(delayTicks) > maxOffsetTicks) { // Session not ready at all. context.SetBuffering(session, true); @@ -501,7 +516,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Client, that was buffering, resumed playback but did not update others in time. delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond; - delayTicks = Math.Max(delayTicks, context.DefaultPing); + delayTicks = Math.Max(delayTicks, TimeSpan.FromMilliseconds(context.DefaultPing).Ticks); context.LastActivity = currentTime.AddTicks(delayTicks); @@ -580,6 +595,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var newItem = context.NextItemInQueue(); + PositionJumped = newItem; if (newItem) { // Send playing-queue update. @@ -626,6 +642,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var newItem = context.PreviousItemInQueue(); + PositionJumped = newItem; if (newItem) { // Send playing-queue update. diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs index ddf86be71f..e02d1bde45 100644 --- a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs +++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs @@ -34,6 +34,12 @@ namespace MediaBrowser.Controller.SyncPlay long MaxPlaybackOffset { get; } /// <summary> + /// Gets the maximum offset accepted for a session catching up after buffering, in milliseconds. + /// </summary> + /// <value>The maximum catch-up offset, in milliseconds.</value> + long MaxCatchUpOffset => 60000; + + /// <summary> /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs index 9326864d78..258b92e4d9 100644 --- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -157,7 +157,10 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// </summary> public void RestoreSortedPlaylist() { - if (PlayingItemIndex != NoPlayingItemIndex) + // The shuffled playlist is only populated while the shuffle mode is active, so there is + // nothing to map back when the playlist is already sorted. Guarding on its contents keeps + // a redundant request for the sorted mode from indexing an empty list. + if (PlayingItemIndex != NoPlayingItemIndex && _shuffledPlaylist.Count > 0) { var playingItem = _shuffledPlaylist[PlayingItemIndex]; PlayingItemIndex = _sortedPlaylist.IndexOf(playingItem); diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index bc184d82fb..d5e233d4f8 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Xml; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; @@ -500,7 +501,8 @@ namespace MediaBrowser.LocalMetadata.Savers { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToResolve] + ItemIds = [.. idsToResolve], + DtoOptions = DtoOptions.StoredColumnsOnly }); pathById = new Dictionary<Guid, string?>(batched.Count); foreach (var batchedItem in batched) diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs index 6ca994fb7e..57c553386a 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs @@ -87,7 +87,7 @@ public class BdInfoExaminer : IBlurayExaminer if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0) { // Get the files in the playlist - outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.FileInfo.FullName).ToArray(); + outputStream.Files = playlist.StreamClips.Where(i => i.AngleIndex == 0).Select(i => i.StreamFile.FileInfo.FullName).ToArray(); } return outputStream; diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 91d0c3d5a6..99c08eb9a1 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -193,8 +193,6 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly string _encoderPath; - private readonly Version _minFFmpegMultiThreadedCli = new Version(7, 0); - public EncoderValidator(ILogger logger, string encoderPath) { _logger = logger; @@ -552,9 +550,9 @@ namespace MediaBrowser.MediaEncoding.Encoder string output; try { - // With multi-threaded cli support, FFmpeg 7 is less sensitive to keyboard input - var duration = ffmpegVersion >= _minFFmpegMultiThreadedCli ? 10000 : 1000; - output = GetProcessOutput(_encoderPath, $"-hide_banner -f lavfi -i nullsrc=s=1x1:d={duration} -f null -", true, "?"); + // Start a dummy encode of 1x1@1fps. Send '?' to stdin to get the help/keybind text, followed by 'q' to stop the job immediately + // As a safeguard in case 'q' doesn't stop the job, the dummy input has a max duration of 5 (realtime) seconds + output = GetProcessOutput(_encoderPath, $"-hide_banner -re -f lavfi -i nullsrc=s=1x1:r=1:d=5 -f null -", true, "?q"); } catch (Exception ex) { diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 0ddd378352..fa43d756a4 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -922,6 +922,15 @@ namespace MediaBrowser.MediaEncoding.Encoder inputArg = "-hwaccel_flags +low_priority " + inputArg; } + // Force the video stream, otherwise ffmpeg may pick a cover image. + var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, imageStream); + if (streamIndex < 0) + { + throw new InvalidOperationException($"Unable to locate requested stream {imageStream.Title}"); + } + + inputArg += " -map 0:" + streamIndex; + var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, vidEncoder).Trim(); if (string.IsNullOrWhiteSpace(filterParam)) { @@ -1152,6 +1161,11 @@ namespace MediaBrowser.MediaEncoding.Encoder { process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; } + catch (InvalidOperationException) + { + // The process finished before its priority could be lowered. That says nothing + // about whether the platform allows it, so keep the capability for the next one. + } catch (Exception ex) { _canSetProcessPriority = false; @@ -1361,12 +1375,20 @@ namespace MediaBrowser.MediaEncoding.Encoder return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction; } - private sealed class ProcessWrapper : IDisposable + internal sealed class ProcessWrapper : IDisposable { private readonly MediaEncoder _mediaEncoder; + // The exit event is raised on the thread pool, so it writes the state below while the + // caller that started the process is reading it. + private readonly Lock _exitLock = new(); + private bool _disposed = false; + private bool _hasExited; + + private int? _exitCode; + public ProcessWrapper(Process process, MediaEncoder mediaEncoder) { Process = process; @@ -1376,49 +1398,84 @@ namespace MediaBrowser.MediaEncoding.Encoder public Process Process { get; } - public bool HasExited { get; private set; } + // The exit event can lag behind the wait that returned, so ask the process rather than + // report one that has exited as still running. + public bool HasExited => ReadExitState().HasExited; - public int? ExitCode { get; private set; } + // As above: rather than report no exit code for a process that has one. + public int? ExitCode => ReadExitState().ExitCode; + + private (bool HasExited, int? ExitCode) ReadExitState() + { + lock (_exitLock) + { + if (!_hasExited && !_disposed) + { + try + { + if (Process.HasExited) + { + _hasExited = true; + _exitCode = Process.ExitCode; + } + } + catch (InvalidOperationException) + { + // No process is associated with this object, or it was disposed from + // under us - ObjectDisposedException derives from this one. + } + } + + return (_hasExited, _exitCode); + } + } private void OnProcessExited(object sender, EventArgs e) { var process = (Process)sender; - HasExited = true; - - try - { - ExitCode = process.ExitCode; - } - catch + lock (_exitLock) { + _hasExited = true; + + try + { + _exitCode = process.ExitCode; + } + catch + { + } } - DisposeProcess(process); + // Only stop tracking it. The caller that started the process still holds it to read + // its output and its exit code, so disposing it here handed whoever was quickest to + // exit - an ffprobe on a file it rejects outright - an ObjectDisposedException. + Untrack(); } - private void DisposeProcess(Process process) + private void Untrack() { lock (_mediaEncoder._runningProcessesLock) { _mediaEncoder._runningProcesses.Remove(this); } - - process.Dispose(); } public void Dispose() { - if (!_disposed) + lock (_exitLock) { - if (Process is not null) + if (_disposed) { - Process.Exited -= OnProcessExited; - DisposeProcess(Process); + return; } + + _disposed = true; } - _disposed = true; + Process.Exited -= OnProcessExited; + Untrack(); + Process.Dispose(); } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index e8c636e7fb..fba644b74a 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -649,7 +649,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles List<MediaStream> subtitleStreams, CancellationToken cancellationToken) { - var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource); + var inputPath = _mediaEncoder.GetInputPathArgument(mediaSource.Path, mediaSource); var outputPaths = new List<string>(); var args = string.Format( CultureInfo.InvariantCulture, @@ -673,7 +673,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files. var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty; - var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); + var streamIndex = EncodingHelper.GetSubtitleStreamIndexForFfmpeg(mediaSource, subtitleStream); if (streamIndex == -1) { diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index ab8d5dd5b2..4799ed5410 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -26,6 +26,8 @@ namespace MediaBrowser.Model.Dlna internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons; internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported; + private const string ManifestContainers = "hls,applehttp,dash"; + private readonly ILogger _logger; private readonly ITranscoderSupport _transcoderSupport; private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"]; @@ -718,6 +720,14 @@ namespace MediaBrowser.Model.Dlna isEligibleForDirectPlay = false; } + // A manifest is not a byte stream, so it cannot be handed to the client as one. The variant + // and segment URIs inside it are relative to the origin and do not resolve against the + // Jellyfin url the client would fetch it from. + if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container)) + { + isEligibleForDirectPlay = false; + } + if (bitrateLimitExceeded) { transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit; diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index f057714bea..67af843626 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -810,6 +810,11 @@ namespace MediaBrowser.Model.Entities return (VideoRange.Unknown, VideoRangeType.Unknown); } + var isPq = string.Equals(ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase); + var isHlg = string.Equals(ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase); + // Invalid DV only retains HDR when the base layer explicitly signals PQ or HLG. + var baseVideoRange = isPq || isHlg ? VideoRange.HDR : VideoRange.SDR; + var codecTag = CodecTag; var dvProfile = DvProfile; var rpuPresentFlag = RpuPresentFlag == 1; @@ -834,7 +839,7 @@ namespace MediaBrowser.Model.Entities 4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG), 2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR), // Out of Dolby Spec files should be marked as invalid - _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid) + _ => (baseVideoRange, VideoRangeType.DOVIInvalid) }, 7 => (VideoRange.HDR, VideoRangeType.DOVIWithEL), 10 => dvBlCompatId switch @@ -844,11 +849,26 @@ namespace MediaBrowser.Model.Entities 2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR), 4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG), // Out of Dolby Spec files should be marked as invalid - _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid) + _ => (baseVideoRange, VideoRangeType.DOVIInvalid) }, _ => (VideoRange.SDR, VideoRangeType.SDR) }; + var expectedTransfer = dvRangeSet.Item2 switch + { + VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithEL => "smpte2084", + VideoRangeType.DOVIWithHLG => "arib-std-b67", + _ => null + }; + + if (expectedTransfer is not null + && (!string.Equals(ColorSpace, "bt2020nc", StringComparison.OrdinalIgnoreCase) + || !string.Equals(ColorTransfer, expectedTransfer, StringComparison.OrdinalIgnoreCase) + || !string.Equals(ColorPrimaries, "bt2020", StringComparison.OrdinalIgnoreCase))) + { + return (baseVideoRange, VideoRangeType.DOVIInvalid); + } + if (Hdr10PlusPresentFlag == true) { return dvRangeSet.Item2 switch @@ -862,13 +882,11 @@ namespace MediaBrowser.Model.Entities return dvRangeSet; } - var colorTransfer = ColorTransfer; - - if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)) + if (isPq) { return Hdr10PlusPresentFlag == true ? (VideoRange.HDR, VideoRangeType.HDR10Plus) : (VideoRange.HDR, VideoRangeType.HDR10); } - else if (string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) + else if (isHlg) { return (VideoRange.HDR, VideoRangeType.HLG); } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index 2dddd39ef4..83b44e8b2d 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Model</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs index a06de95fce..61615b288a 100644 --- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -54,9 +54,10 @@ public class ComicBookInfoProvider : IComicProvider var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false); await using (archive.ConfigureAwait(false)) { - if (archive.Comment is null) + // ZipArchive.Comment is an empty string, not null, when the archive has no comment + if (string.IsNullOrWhiteSpace(archive.Comment)) { - _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path); + _logger.LogDebug("missing ComicBookInfo in archive comment: {Path}", info.Path); return new MetadataResult<Book> { HasMetadata = false }; } @@ -71,6 +72,12 @@ public class ComicBookInfoProvider : IComicProvider } } } + catch (JsonException ex) + { + // the archive comment is not reserved for ComicBookInfo, so any other content is not an error + _logger.LogDebug("archive comment is not valid ComicBookInfo metadata: {Path}: {Message}", info.Path, ex.Message); + return new MetadataResult<Book> { HasMetadata = false }; + } catch (Exception ex) { _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path); diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index af31e373ef..a19262c3a7 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -32,6 +32,7 @@ public class LyricManager : ILyricManager private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; private readonly IMediaSourceManager _mediaSourceManager; + private readonly IDirectoryService _directoryService; private readonly ILyricProvider[] _lyricProviders; private readonly ILyricParser[] _lyricParsers; @@ -43,6 +44,7 @@ public class LyricManager : ILyricManager /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param> /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param> public LyricManager( @@ -50,6 +52,7 @@ public class LyricManager : ILyricManager IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IMediaSourceManager mediaSourceManager, + IDirectoryService directoryService, IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers) { @@ -57,6 +60,7 @@ public class LyricManager : ILyricManager _fileSystem = fileSystem; _libraryMonitor = libraryMonitor; _mediaSourceManager = mediaSourceManager; + _directoryService = directoryService; _lyricProviders = lyricProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); @@ -250,6 +254,8 @@ public class LyricManager : ILyricManager { _libraryMonitor.ReportFileSystemChangeComplete(path, false); } + + _directoryService.Invalidate(path); } return audio.RefreshMetadata(CancellationToken.None); @@ -446,6 +452,8 @@ public class LyricManager : ILyricManager await stream.CopyToAsync(fs).ConfigureAwait(false); } + _directoryService.Invalidate(savePath); + return; } catch (Exception ex) diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 727f481b65..ce5468c5c4 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -254,6 +254,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + result.Failures++; result.ErrorMessage = ex.Message; _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } @@ -338,6 +339,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + result.Failures++; result.ErrorMessage = ex.Message; _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index fe5285bf65..05c542337e 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -192,9 +192,13 @@ namespace MediaBrowser.Providers.Manager } } - // Next run remote image providers, but only if local image providers didn't throw an exception - if (!localImagesFailed && refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly) + if (localImagesFailed) { + hasRefreshedImages = false; + } + else if (refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly) + { + // Next run remote image providers, now that local image providers didn't throw var providers = GetNonLocalImageProviders(item, allImageProviders, refreshOptions).ToList(); if (providers.Count > 0) @@ -937,6 +941,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + refreshResult.Failures++; refreshResult.ErrorMessage = ex.Message; Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index fbd9e5435e..8387f24bc9 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -76,7 +76,8 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Cache for ordered metadata providers per library/item type combination. - /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata). + /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata), where + /// LibraryPath is the collection folder path the library options are stored against. /// Value: Array of ordered metadata providers (before per-item filtering). /// </summary> private readonly ConcurrentDictionary<MetadataProviderCacheKey, IMetadataProvider[]> _metadataProviderCache = new(); @@ -136,6 +137,7 @@ namespace MediaBrowser.Providers.Manager _similarItemsManager = similarItemsManager; CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated; + _configurationManager.ConfigurationUpdated += OnConfigurationUpdated; } /// <inheritdoc/> @@ -476,15 +478,15 @@ namespace MediaBrowser.Providers.Manager return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, includeDisabled, false, libraryPath); } - private static string GetLibraryPathForItem(BaseItem item) + private string GetLibraryPathForItem(BaseItem item) { if (item is CollectionFolder collectionFolder) { return collectionFolder.Path ?? string.Empty; } - var topParent = item.GetTopParent(); - return topParent?.Path ?? string.Empty; + return _libraryManager.GetCollectionFolders(item) + .Find(folder => folder is CollectionFolder)?.Path ?? string.Empty; } /// <inheritdoc /> @@ -1143,16 +1145,21 @@ namespace MediaBrowser.Providers.Manager return; } - _refreshQueue.Enqueue((itemId, options), priority); - + // PriorityQueue is not thread safe and the processor dequeues concurrently, so every + // touch of the queue takes the lock. lock (_refreshQueueLock) { - if (!_isProcessingRefreshQueue) + _refreshQueue.Enqueue((itemId, options), priority); + + if (_isProcessingRefreshQueue) { - _isProcessingRefreshQueue = true; - Task.Run(StartProcessingRefreshQueue); + return; } + + _isProcessingRefreshQueue = true; } + + Task.Run(StartProcessingRefreshQueue); } private async Task StartProcessingRefreshQueue() @@ -1161,17 +1168,33 @@ namespace MediaBrowser.Providers.Manager if (_disposed) { + lock (_refreshQueueLock) + { + _isProcessingRefreshQueue = false; + } + return; } var cancellationToken = _disposeCancellationTokenSource.Token; libraryManager.ClearIgnoreRuleCache(); - while (_refreshQueue.TryDequeue(out var refreshItem, out _)) + + while (true) { - if (_disposed) + (Guid ItemId, MetadataRefreshOptions RefreshOptions) refreshItem; + + // Dequeueing and standing down happen under one lock, otherwise a refresh queued + // just after the queue ran dry would see a processor that has already stopped. + lock (_refreshQueueLock) { - return; + if (_disposed + || cancellationToken.IsCancellationRequested + || !_refreshQueue.TryDequeue(out refreshItem, out _)) + { + _isProcessingRefreshQueue = false; + break; + } } try @@ -1188,19 +1211,21 @@ namespace MediaBrowser.Providers.Manager await task.ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - break; + // Shutting down: the next pass sees the token and stands the processor down. + continue; } catch (Exception ex) { + // Includes a provider that cancelled for its own reasons, such as an HTTP + // timeout, which must not stop the queue draining. _logger.LogError(ex, "Error refreshing item"); } } - lock (_refreshQueueLock) + if (!_disposed) { - _isProcessingRefreshQueue = false; libraryManager.ClearIgnoreRuleCache(); } } @@ -1291,6 +1316,7 @@ namespace MediaBrowser.Providers.Manager if (disposing) { CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated; + _configurationManager.ConfigurationUpdated -= OnConfigurationUpdated; if (!_disposeCancellationTokenSource.IsCancellationRequested) { @@ -1318,6 +1344,11 @@ namespace MediaBrowser.Providers.Manager _logger.LogDebug("Invalidated metadata provider cache for library: {LibraryPath}", e.LibraryPath); } + private void OnConfigurationUpdated(object? sender, EventArgs e) + { + ClearMetadataProviderCache(); + } + internal void ClearMetadataProviderCache() { _metadataProviderCache.Clear(); @@ -1327,7 +1358,7 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Cache key for metadata provider lookups. /// </summary> - /// <param name="LibraryPath">The library path for the collection folder.</param> + /// <param name="LibraryPath">The path of the collection folder providing the library options.</param> /// <param name="ItemTypeName">The item type name.</param> /// <param name="IncludeDisabled">Whether to include disabled providers.</param> /// <param name="ForceEnableInternetMetadata">Whether internet metadata is force-enabled.</param> diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 2b0f480b1c..7c3e1867ef 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -26,6 +26,7 @@ <PackageReference Include="SharpCompress" /> <PackageReference Include="z440.atl.core" /> <PackageReference Include="TMDbLib" /> + <PackageReference Include="UTF.Unknown" /> </ItemGroup> <PropertyGroup> diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index 6f9d5f19da..ecb6e5d990 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -231,10 +231,28 @@ namespace MediaBrowser.Providers.MediaInfo return Array.Empty<ExternalPathParserResult>(); } + // VobSub .sub payloads only carry per-track language metadata when read via + // their paired .idx file, so probe the .idx instead and skip the .sub. Pairing + // requires the same directory (ffprobe can't resolve a split pair) and an + // ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive + // filesystems, so a looser match could suppress a .sub with no working .idx). + // An .idx file with no paired .sub cannot be probed at all, so it is left out + // entirely rather than surfaced (which would otherwise fail every probe and, + // since the .idx would keep "existing" from Jellyfin's point of view, prevent + // stale subtitle stream metadata from ever being cleared once the .sub is gone). + HashSet<string>? pairedVobSubKeys = _type == DlnaProfileType.Subtitle + ? GetPairedVobSubKeys(files) + : null; + var externalPathInfos = new List<ExternalPathParserResult>(); ReadOnlySpan<char> prefix = video.FileNameWithoutExtension; foreach (var file in files) { + if (IsSuppressedVobSubFile(file, pairedVobSubKeys)) + { + continue; + } + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan()); if (fileNameWithoutExtension.Length >= prefix.Length && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase) @@ -305,6 +323,77 @@ namespace MediaBrowser.Providers.MediaInfo } /// <summary> + /// Determines whether a candidate file is part of a VobSub .idx/.sub pair that + /// should be resolved to only its .idx file, or an .idx file with no paired .sub + /// that cannot be probed at all. + /// </summary> + /// <param name="file">The full path to the candidate file.</param> + /// <param name="pairedVobSubKeys">The set of pairing keys with both an .idx and .sub present, or null if not applicable.</param> + /// <returns><c>true</c> if the file should be suppressed; otherwise, <c>false</c>.</returns> + private static bool IsSuppressedVobSubFile(string file, HashSet<string>? pairedVobSubKeys) + { + if (pairedVobSubKeys is null) + { + return false; + } + + var extension = Path.GetExtension(file.AsSpan()); + if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) + { + // A paired .idx exists; probe it instead of the .sub payload. + return pairedVobSubKeys.Contains(GetVobSubPairingKey(file)); + } + + if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase)) + { + // Without its .sub payload, the .idx cannot be probed for any data. + return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file)); + } + + return false; + } + + /// <summary> + /// Builds the set of directory+basename keys that have both an .idx and a .sub + /// file present, in a single pass over the candidate files. + /// </summary> + /// <param name="files">The candidate files to search.</param> + /// <returns>The set of pairing keys with both an .idx and .sub present.</returns> + private static HashSet<string> GetPairedVobSubKeys(IEnumerable<string> files) + { + var idxKeys = new HashSet<string>(StringComparer.Ordinal); + var subKeys = new HashSet<string>(StringComparer.Ordinal); + foreach (var file in files) + { + var extension = Path.GetExtension(file.AsSpan()); + if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase)) + { + idxKeys.Add(GetVobSubPairingKey(file)); + } + else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) + { + subKeys.Add(GetVobSubPairingKey(file)); + } + } + + idxKeys.IntersectWith(subKeys); + return idxKeys; + } + + /// <summary> + /// Builds a directory+basename key used to pair a VobSub .idx file with its .sub + /// payload only when both live in the same directory. + /// </summary> + /// <param name="file">The full path to the file.</param> + /// <returns>A key combining the containing directory and file name without extension.</returns> + private static string GetVobSubPairingKey(string file) + { + var directory = Path.GetDirectoryName(file) ?? string.Empty; + var baseName = Path.GetFileNameWithoutExtension(file); + return Path.Combine(directory, baseName); + } + + /// <summary> /// Returns the media info of the given file. /// </summary> /// <param name="path">The path to the file.</param> diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 221c6bff5e..5d78cdd0be 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -142,6 +142,12 @@ namespace MediaBrowser.Providers.MediaInfo } } + if (IsMissingMediaInfo(item)) + { + _logger.LogDebug("Refreshing {ItemPath} because it has no media information.", item.Path); + return true; + } + if (video is not null && item.SupportsLocalMetadata && !video.IsPlaceHolder) @@ -175,6 +181,25 @@ namespace MediaBrowser.Providers.MediaInfo return false; } + private static bool IsMissingMediaInfo(BaseItem item) + { + if (item.RunTimeTicks.HasValue + || item.TotalBitrate.HasValue + || item.IsVirtualItem + || item.IsShortcut + || !item.IsFileProtocol) + { + return false; + } + + return item switch + { + Video video => !video.IsPlaceHolder && video.IsCompleteMedia, + Audio => true, + _ => false + }; + } + /// <inheritdoc /> public Task<ItemUpdateType> FetchAsync(Episode item, MetadataRefreshOptions options, CancellationToken cancellationToken) { diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 924fde4808..4952d03e8a 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -15,6 +16,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using PlaylistsNET.Content; +using UtfUnknown; namespace MediaBrowser.Providers.Playlists; @@ -26,6 +28,11 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, IForcedProvider, IHasItemChangeMonitor { + /// <summary> + /// Minimum confidence required before a detected encoding is preferred over UTF-8. + /// </summary> + private const float MinimumEncodingConfidence = 0.5f; + private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; private readonly ILogger<PlaylistItemsProvider> _logger; @@ -136,23 +143,38 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new PlsContent(); - var playlist = content.GetFromStream(stream); + var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath)); return playlist.PlaylistEntries .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) .Where(i => i is not null); } - private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) + internal IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new M3uContent(); - var playlist = content.GetFromStream(stream); + var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath)); return playlist.PlaylistEntries .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) .Where(i => i is not null); } + private Encoding DetectEncoding(Stream stream, string playlistPath) + { + var detected = CharsetDetector.DetectFromStream(stream).Detected; + stream.Seek(0, SeekOrigin.Begin); + + if (detected?.Encoding is null || detected.Confidence < MinimumEncodingConfidence) + { + _logger.LogDebug("Could not detect the encoding of playlist {Path}, assuming UTF-8", playlistPath); + return Encoding.UTF8; + } + + _logger.LogDebug("Detected encoding {Encoding} for playlist {Path}", detected.Encoding.WebName, playlistPath); + return detected.Encoding; + } + private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new ZplContent(); @@ -191,7 +213,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, { item = null; string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath); - if (!File.Exists(pathToCheck)) + if (!File.Exists(pathToCheck) && !TryNormalizePath(ref pathToCheck)) { return false; } @@ -208,6 +230,36 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, return false; } + private static bool TryNormalizePath(ref string path) + { + foreach (var form in new[] { NormalizationForm.FormC, NormalizationForm.FormD }) + { + string normalized; + try + { + if (path.IsNormalized(form)) + { + continue; + } + + normalized = path.Normalize(form); + } + catch (ArgumentException) + { + // The path is not valid Unicode, there is nothing to normalize. + return false; + } + + if (File.Exists(normalized)) + { + path = normalized; + return true; + } + } + + return false; + } + /// <inheritdoc /> public bool HasChanged(BaseItem item, IDirectoryService directoryService) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index ef952082da..9501da7ec6 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -163,12 +163,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); + var searchYear = info.Year ?? parsedName.Year ?? 0; - var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, searchYear, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResults?.Count > 0) + var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear); + + if (match is not null) { - tmdbId = searchResults[0].Id; + tmdbId = match.Id; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs index 5206de78ce..6a3c72d2fa 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs @@ -58,7 +58,7 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> } var providerName = MetadataProvider.Tmdb.ToString(); - var page = 0; + var page = 1; var totalPages = 1; while (page <= totalPages && !cancellationToken.IsCancellationRequested) @@ -67,12 +67,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> try { (pageResults, totalPages) = await _tmdbClientManager - .GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .GetMovieRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page); + _logger.LogWarning(ex, "Failed to get recommended movies from TMDb for {TmdbId} page {Page}", tmdbId, page); yield break; } @@ -81,12 +81,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> yield break; } - foreach (var similar in pageResults) + foreach (var recommendation in pageResults) { yield return new SimilarItemReference { ProviderName = providerName, - ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture) }; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 6163e20194..b6bb7aa013 100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -202,11 +202,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); + var searchYear = info.Year ?? parsedName.Year ?? 0; + var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, searchYear, cancellationToken).ConfigureAwait(false); - if (searchResults?.Count > 0) + var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear); + + if (match is not null) { - tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); + tmdbId = match.Id.ToString(CultureInfo.InvariantCulture); } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs index c85718b993..40c7de05ad 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs @@ -67,12 +67,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series> try { (pageResults, totalPages) = await _tmdbClientManager - .GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .GetSeriesRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page); + _logger.LogWarning(ex, "Failed to get recommended TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page); yield break; } @@ -81,12 +81,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series> yield break; } - foreach (var similar in pageResults) + foreach (var recommendation in pageResults) { yield return new SimilarItemReference { ProviderName = providerName, - ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture) }; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index ccc714278d..d75ebae988 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -29,6 +29,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb // differ in weight by orders of magnitude. private const int CacheSizeLimit = 100_000; + private static readonly Dictionary<string, string> ThumbnailSizes = new Dictionary<string, string> + { + { "Primary", "w500" }, + { "Backdrop", "w780" }, + { "Thumb", "w780" }, + { "Logo", "w500" }, + }; + private readonly MemoryCache _memoryCache; private readonly TMDbClient _tmDbClient; @@ -326,7 +334,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb person = await _tmDbClient.GetPersonAsync( personTmdbId, TmdbUtils.NormalizeLanguage(language, countryCode), - PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds, + PersonMethods.Images | PersonMethods.ExternalIds, cancellationToken).ConfigureAwait(false); if (person is not null) @@ -508,19 +516,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Gets a single page of similar movies for a movie from the TMDb API. + /// Gets a single page of recommended movies for a movie from the TMDb API. /// </summary> /// <param name="tmdbId">The TMDb id of the movie.</param> /// <param name="page">The page number to fetch (1-based).</param> /// <param name="language">The language for results.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns> - public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + /// <returns>A tuple containing the list of recommended movies and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .GetMovieSimilarAsync(tmdbId, language, page, cancellationToken) + .GetMovieRecommendationsAsync(tmdbId, language, page, cancellationToken) .ConfigureAwait(false); if (searchResults?.Results is null || searchResults.Results.Count == 0) @@ -532,19 +540,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Gets a single page of similar TV shows for a series from the TMDb API. + /// Gets a single page of recommended TV shows for a series from the TMDb API. /// </summary> /// <param name="tmdbId">The TMDb id of the TV show.</param> /// <param name="page">The page number to fetch (1-based).</param> /// <param name="language">The language for results.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns> - public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + /// <returns>A tuple containing the list of recommended TV shows and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken) + .GetTvShowRecommendationsAsync(tmdbId, language, page, cancellationToken) .ConfigureAwait(false); if (searchResults?.Results is null || searchResults.Results.Count == 0) @@ -568,8 +576,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return null; } - // Use "original" as default size if size is null or empty to prevent malformed URLs - var imageSize = string.IsNullOrEmpty(size) ? "original" : size; + // Use the original size as default if size is null or empty to prevent malformed URLs + var imageSize = string.IsNullOrEmpty(size) ? TmdbUtils.OriginalImageSize : size; return _tmDbClient.GetImageUrl(imageSize, path, true).ToString(); } @@ -660,7 +668,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string? size, ImageType type, string requestLanguage) { // sizes provided are for original resolution, don't store them when downloading scaled images - var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase); + var scaleImage = !TmdbUtils.IsOriginalImageSize(size); for (var i = 0; i < images.Count; i++) { @@ -678,6 +686,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb yield return new RemoteImageInfo { Url = GetUrl(size, image.FilePath), + ThumbnailUrl = GetUrl(ThumbnailSizes.GetValueOrDefault(type.ToString(), string.Empty), image.FilePath), CommunityRating = image.VoteAverage, VoteCount = image.VoteCount, Width = scaleImage ? null : image.Width, diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 44a2f7291e..f004251594 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -8,6 +8,7 @@ using System.Text.RegularExpressions; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; +using TMDbLib.Objects.Search; using TMDbLib.Objects.TvShows; using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; @@ -34,6 +35,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public const string ApiKey = "4219e299c89411838049ab0dab19ebd5"; /// <summary> + /// The image size representing the unscaled image as served by TMDb. + /// </summary> + public const string OriginalImageSize = "original"; + + private const int TitleExactScore = 8; + private const int TitlePrefixScore = 4; + private const int YearExactScore = 2; + private const int YearAdjacentScore = 1; + + /// <summary> /// The crew types to keep. /// </summary> public static readonly string[] WantedCrewTypes = @@ -63,8 +74,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb "novel" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); - [GeneratedRegex(@"[\W_-[·]]+")] - private static partial Regex NonWordRegex(); + /// <summary> + /// Everything that is not a letter, a number or a combining mark separates two search terms. The + /// interpunct is kept because TMDb uses it inside titles such as "WALL·E", where it matches better + /// than a space does. + /// </summary> + [GeneratedRegex(@"[^\p{L}\p{N}\p{M}·]+")] + private static partial Regex NonSearchTermRegex(); + + /// <summary> + /// As <see cref="NonSearchTermRegex"/>, but the interpunct is a separator too, so a "WALL-E" folder + /// and the "WALL·E" title TMDb returns compare equal. + /// </summary> + [GeneratedRegex(@"[^\p{L}\p{N}\p{M}]+")] + private static partial Regex NonComparableRegex(); /// <summary> /// Gets the TMDb id of an item, if it has one TMDb can be queried with. @@ -101,7 +124,140 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public static string CleanName(string name) { // TMDb expects a space separated list of words make sure that is the case - return NonWordRegex().Replace(name, " "); + return NonSearchTermRegex().Replace(name, " ").Trim(); + } + + /// <summary> + /// Reduces a title to the form used to compare a local name against a TMDb search result. + /// </summary> + /// <param name="title">The title to normalize.</param> + /// <returns>The normalized title, or an empty string if there was nothing to normalize.</returns> + public static string NormalizeTitle(string? title) + { + return string.IsNullOrEmpty(title) + ? string.Empty + : NonComparableRegex().Replace(title, " ").Trim().ToLowerInvariant(); + } + + /// <summary> + /// Picks the movie search result that best matches the name and year an item was looked up by. + /// </summary> + /// <param name="results">The search results, in the order TMDb returned them.</param> + /// <param name="name">The parsed name of the local item.</param> + /// <param name="year">The year of the local item, or 0 if it is unknown.</param> + /// <returns>The best match, or <c>null</c> if there were no results.</returns> + public static SearchMovie? FindBestMatch(IReadOnlyList<SearchMovie>? results, string? name, int year) + { + return FindBestMatch( + results, + name, + year, + static movie => movie.Title, + static movie => movie.OriginalTitle, + static movie => movie.ReleaseDate); + } + + /// <summary> + /// Picks the series search result that best matches the name and year an item was looked up by. + /// </summary> + /// <param name="results">The search results, in the order TMDb returned them.</param> + /// <param name="name">The parsed name of the local item.</param> + /// <param name="year">The year of the local item, or 0 if it is unknown.</param> + /// <returns>The best match, or <c>null</c> if there were no results.</returns> + public static SearchTv? FindBestMatch(IReadOnlyList<SearchTv>? results, string? name, int year) + { + return FindBestMatch( + results, + name, + year, + static series => series.Name, + static series => series.OriginalName, + static series => series.FirstAirDate); + } + + /// <summary> + /// Picks the search result that best matches the name and year an item was looked up by. + /// </summary> + /// <remarks> + /// TMDb's year parameter only nudges relevance, it does not filter, so the first hit is regularly a + /// different film or show that happens to share the title - searching for "Mulan" with year 2020 + /// returns the 1998 film first. A title that matches outranks one that does not, and the year only + /// separates candidates that are otherwise equally good. When nothing matches at all TMDb's own + /// ordering is kept, so a name that needs fuzzy matching, such as "A Christmas No. 1" for + /// "A Christmas Number One", still resolves. + /// </remarks> + private static T? FindBestMatch<T>( + IReadOnlyList<T>? results, + string? name, + int year, + Func<T, string?> titleSelector, + Func<T, string?> originalTitleSelector, + Func<T, DateTime?> releaseDateSelector) + where T : class + { + if (results is null || results.Count == 0) + { + return null; + } + + var normalizedName = NormalizeTitle(name); + if (normalizedName.Length == 0) + { + return results[0]; + } + + var best = results[0]; + var bestScore = 0; + + foreach (var result in results) + { + var score = Math.Max( + ScoreTitle(normalizedName, titleSelector(result)), + ScoreTitle(normalizedName, originalTitleSelector(result))) + + ScoreYear(year, releaseDateSelector(result)?.Year); + + // Strictly greater, so ties keep the earlier, more relevant result. + if (score > bestScore) + { + bestScore = score; + best = result; + } + } + + return best; + } + + private static int ScoreTitle(string normalizedName, string? title) + { + var normalizedTitle = NormalizeTitle(title); + + if (string.Equals(normalizedName, normalizedTitle, StringComparison.Ordinal)) + { + return TitleExactScore; + } + + // Whole words only, otherwise "Wall" half matches "Wall Street". + return normalizedTitle.Length > normalizedName.Length + && normalizedTitle[normalizedName.Length] == ' ' + && normalizedTitle.StartsWith(normalizedName, StringComparison.Ordinal) + ? TitlePrefixScore + : 0; + } + + private static int ScoreYear(int year, int? resultYear) + { + if (year <= 0 || resultYear is not int candidateYear) + { + return 0; + } + + return Math.Abs(candidateYear - year) switch + { + 0 => YearExactScore, + // Regional release dates routinely straddle a new year. + 1 => YearAdjacentScore, + _ => 0 + }; } /// <summary> @@ -335,6 +491,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Determines whether the configured image size fetches the image at its original resolution. + /// An unset size falls back to <see cref="OriginalImageSize"/>, see TmdbClientManager.GetUrl. + /// </summary> + /// <param name="size">The configured image size.</param> + /// <returns><c>true</c> if the original image is fetched; otherwise, <c>false</c>.</returns> + public static bool IsOriginalImageSize(string? size) + => string.IsNullOrEmpty(size) || string.Equals(size, OriginalImageSize, StringComparison.OrdinalIgnoreCase); + + /// <summary> /// Combines the metadata country code and the parental rating from the API into the value we store in our database. /// </summary> /// <param name="countryCode">The ISO 3166-1 country code of the rating country.</param> diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index c3458d4b2a..cd9dda21a0 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -33,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles private readonly ILibraryMonitor _monitor; private readonly IMediaSourceManager _mediaSourceManager; private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly HashSet<string> _allowedSubtitleFormats; private readonly ISubtitleProvider[] _subtitleProviders; @@ -43,6 +44,7 @@ namespace MediaBrowser.Providers.Subtitles ILibraryMonitor monitor, IMediaSourceManager mediaSourceManager, ILocalizationManager localizationManager, + IDirectoryService directoryService, IEnumerable<ISubtitleProvider> subtitleProviders, NamingOptions namingOptions) { @@ -51,6 +53,7 @@ namespace MediaBrowser.Providers.Subtitles _monitor = monitor; _mediaSourceManager = mediaSourceManager; _localization = localizationManager; + _directoryService = directoryService; _subtitleProviders = subtitleProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); @@ -281,6 +284,8 @@ namespace MediaBrowser.Providers.Subtitles await stream.CopyToAsync(fs).ConfigureAwait(false); } + _directoryService.Invalidate(path); + return; } else @@ -395,6 +400,8 @@ namespace MediaBrowser.Providers.Subtitles _monitor.ReportFileSystemChangeComplete(path, false); } + _directoryService.Invalidate(path); + return item.RefreshMetadata(CancellationToken.None); } diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index 596ca8d201..f662ac2367 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -44,6 +44,31 @@ public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo> { var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); + // An episode cannot end before it starts. + if (item.IndexNumberEnd < item.IndexNumber) + { + Logger.LogWarning( + "Discarding episode range end {IndexNumberEnd} preceding episode number {IndexNumber} for {Path}", + item.IndexNumberEnd, + item.IndexNumber, + item.Path); + + item.IndexNumberEnd = null; + updatedType |= ItemUpdateType.MetadataImport; + } + else if (item.IndexNumberEnd.HasValue && !item.IndexNumber.HasValue) + { + // Without a first episode the end does not describe a range. Promoting it to the episode number + // would invent an identity the metadata never supplied, so drop the orphaned value instead. + Logger.LogWarning( + "Discarding episode range end {IndexNumberEnd} without an episode number for {Path}", + item.IndexNumberEnd, + item.Path); + + item.IndexNumberEnd = null; + updatedType |= ItemUpdateType.MetadataImport; + } + var seriesName = item.FindSeriesName(); if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal)) { diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs index 19b1bbe7b6..c3e5c791c0 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Xml; @@ -44,41 +46,60 @@ namespace MediaBrowser.XbmcMetadata.Parsers var xmlFile = File.ReadAllText(metadataFile); - var srch = "</episodedetails>"; - var index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase); - - var xml = xmlFile; + // Split the nfo into its episodedetails blocks. + // This is needed because XBMC metadata uses multiple episodedetails blocks instead of an episodenumberend tag. + const string Srch = "</episodedetails>"; + var blocks = new List<string>(); + int index; + while ((index = xmlFile.IndexOf(Srch, StringComparison.OrdinalIgnoreCase)) != -1) + { + blocks.Add(xmlFile.Substring(0, index + Srch.Length)); + xmlFile = xmlFile.Substring(index + Srch.Length); + } - if (index != -1) + if (blocks.Count == 0) { - xml = xmlFile.Substring(0, index + srch.Length); - xmlFile = xmlFile.Substring(index + srch.Length); + // No closing tag, let the xml reader deal with whatever is in the file + blocks.Add(xmlFile); } // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions try { - // Extract episode details from the first episodedetails block - ReadEpisodeDetailsFromXml(item, xml, settings, cancellationToken); + if (blocks.Count == 1) + { + ReadEpisodeDetailsFromXml(item, blocks[0], settings, cancellationToken); + return; + } + + // The blocks are not guaranteed to be written in ascending episode order, so parse them all + // and sort them before merging. + var episodes = blocks + .Select(block => + { + var episode = new MetadataResult<Episode>() + { + Item = new Episode() + }; - // Extract the last episode number from nfo - // Retrieves all additional episodedetails blocks from the rest of the nfo and concatenates the name, originalTitle and overview tags with the first episode - // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag + ReadEpisodeDetailsFromXml(episode, block, settings, cancellationToken); + + return (Xml: block, Result: episode); + }) + .OrderBy(episode => episode.Result.Item.IndexNumber ?? int.MaxValue) + .ToList(); + + // Extract the details of the lowest numbered episode into the item that is returned to the caller + ReadEpisodeDetailsFromXml(item, episodes[0].Xml, settings, cancellationToken); + + // Concatenate the name, originalTitle and overview tags of the remaining episodes with the first one + // and take the highest episode number as the last episode of the file var name = new StringBuilder(item.Item.Name); var originalTitle = new StringBuilder(item.Item.OriginalTitle); var overview = new StringBuilder(item.Item.Overview); - while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1) + for (var i = 1; i < episodes.Count; i++) { - xml = xmlFile.Substring(0, index + srch.Length); - xmlFile = xmlFile.Substring(index + srch.Length); - - var additionalEpisode = new MetadataResult<Episode>() - { - Item = new Episode() - }; - - // Extract episode details from additional episodedetails block - ReadEpisodeDetailsFromXml(additionalEpisode, xml, settings, cancellationToken); + var additionalEpisode = episodes[i].Result; if (!string.IsNullOrEmpty(additionalEpisode.Item.Name)) { diff --git a/SharedVersion.cs b/SharedVersion.cs index 1d4a368aa2..0b2ab6a013 100644 --- a/SharedVersion.cs +++ b/SharedVersion.cs @@ -1,4 +1,4 @@ using System.Reflection; -[assembly: AssemblyVersion("12.0.0")] -[assembly: AssemblyFileVersion("12.0.0")] +[assembly: AssemblyVersion("13.0.0")] +[assembly: AssemblyFileVersion("13.0.0")] diff --git a/bump_version b/bump_version index 7525721c0c..0516a1806d 100755 --- a/bump_version +++ b/bump_version @@ -27,7 +27,6 @@ jellyfin_subprojects=( MediaBrowser.Model/MediaBrowser.Model.csproj Emby.Naming/Emby.Naming.csproj src/Jellyfin.Extensions/Jellyfin.Extensions.csproj - src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj ) issue_template_file="./.github/ISSUE_TEMPLATE/issue report.yml" diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index b821476390..2ba3faf5f7 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -22,6 +22,65 @@ public static class DescendantQueryHelper b => !b.IsFolder && !b.IsVirtualItem; /// <summary> + /// Gets the predicate identifying the items that stand on their own in a library. An alternate + /// version is a second file for the item that links it rather than an item beside it, and an owned + /// item belongs to its owner unless it is an extra (a trailer and the like, which carries both an + /// owner and an extra type). Nothing here turns on who is asking, so a count that applies it + /// answers the same with a user and without one. + /// </summary> + public static Expression<Func<BaseItemEntity, bool>> IsDistinctLibraryItem { get; } = + b => !b.PrimaryVersionId.HasValue && (!b.OwnerId.HasValue || b.ExtraType != null); + + /// <summary> + /// Builds the predicate identifying the items a user has played, counting a multi-version item as + /// played when any of its alternate versions is. Mirrors the aggregation + /// <c>VersionResumeData.ApplyTo</c> performs on the played flag a single item reports, so that a + /// folder's unplayed count cannot disagree with the watched state its members render with. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The predicate matching the items that user has played.</returns> + public static Expression<Func<BaseItemEntity, bool>> IsPlayedBy(Guid userId) => + b => b.UserData!.Any(u => u.UserId.Equals(userId) && u.Played) + || b.LinkedChildEntities!.Any(lc => + (lc.ChildType == LinkedChildType.LocalAlternateVersion || lc.ChildType == LinkedChildType.LinkedAlternateVersion) + && lc.Child!.UserData!.Any(u => u.UserId.Equals(userId) && u.Played)); + + /// <summary> + /// Builds the projection pairing an item's id with <see cref="IsPlayedBy"/> evaluated on that same + /// row. A caller that needs the flag alongside the id composes it rather than testing membership of + /// the played set: as a sub-select the set is unbounded by whatever the caller joins it to, so the + /// database builds it from the whole table once per place it appears. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The projection of each item onto its id and that user's played state.</returns> + public static Expression<Func<BaseItemEntity, LeafPlayedState>> PlayedStateBy(Guid userId) + { + var played = IsPlayedBy(userId); + var item = played.Parameters[0]; + + // Named members, as the compiler emits for an anonymous type: without them the query provider + // cannot read a later `x.Id` back to the column it was built from and gives up translating. + return Expression.Lambda<Func<BaseItemEntity, LeafPlayedState>>( + Expression.New( + typeof(LeafPlayedState).GetConstructor([typeof(Guid), typeof(bool)])!, + [Expression.Property(item, nameof(BaseItemEntity.Id)), played.Body], + [typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Id))!, typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Played))!]), + item); + } + + /// <summary> + /// Builds the negation of <see cref="IsPlayedBy"/>, so a caller filtering for unplayed items reads + /// the same definition of played as one filtering for played items. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The predicate matching the items that user has not played.</returns> + public static Expression<Func<BaseItemEntity, bool>> IsUnplayedBy(Guid userId) + { + var played = IsPlayedBy(userId); + return Expression.Lambda<Func<BaseItemEntity, bool>>(Expression.Not(played.Body), played.Parameters); + } + + /// <summary> /// Gets a queryable of all descendant IDs for a parent item. /// Traverses AncestorIds and LinkedChildren to find all descendants. /// </summary> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs index 27dbeaba6a..0a72287ba9 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs @@ -37,14 +37,18 @@ public interface IJellyfinDatabaseProvider void ConfigureConventions(ModelConfigurationBuilder configurationBuilder); /// <summary> - /// If supported this should run any periodic maintaince tasks. + /// If supported this should run any periodic maintaince tasks, reclaiming unused space and refreshing the query + /// planner statistics. Also used after migrations have modified the database. /// </summary> /// <param name="cancellationToken">The token to abort the operation.</param> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> Task RunScheduledOptimisation(CancellationToken cancellationToken); /// <summary> - /// If supported this should perform any actions that are required on stopping the jellyfin server. + /// 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 + /// the size of the database. /// </summary> /// <param name="cancellationToken">The token that will be used to abort the operation.</param> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs new file mode 100644 index 0000000000..0846013c45 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs @@ -0,0 +1,10 @@ +using System; + +namespace Jellyfin.Database.Implementations; + +/// <summary> +/// An item's id paired with whether a given user has played it. +/// </summary> +/// <param name="Id">The id of the item.</param> +/// <param name="Played">Whether the user has played the item.</param> +public readonly record struct LeafPlayedState(Guid Id, bool Played); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs index a7f5e369ab..156f553fb3 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs @@ -11,27 +11,19 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(""" -DELETE FROM BaseItems - WHERE - ParentId IS NOT NULL - AND - NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); -DELETE FROM BaseItems - WHERE - ParentId IS NOT NULL - AND - NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); -DELETE FROM BaseItems - WHERE - ParentId IS NOT NULL - AND - NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); -DELETE FROM BaseItems - WHERE - ParentId IS NOT NULL - AND - NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); -"""); + WITH RECURSIVE Orphan ("Id") AS ( + SELECT Child."Id" + FROM "BaseItems" AS Child + WHERE Child."ParentId" IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM "BaseItems" AS Parent WHERE Parent."Id" = Child."ParentId") + UNION + SELECT Descendant."Id" + FROM "BaseItems" AS Descendant + INNER JOIN Orphan ON Descendant."ParentId" = Orphan."Id" + ) + DELETE FROM "BaseItems" WHERE "Id" IN (SELECT "Id" FROM Orphan); + """); + migrationBuilder.AddForeignKey( name: "FK_BaseItems_BaseItems_ParentId", table: "BaseItems", 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 4927b0e78d..379da0e9be 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 @@ -11,6 +11,61 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations /// <inheritdoc /> protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.Sql( + """ + DROP TABLE IF EXISTS "OrphanedBaseItemIds"; + CREATE TEMPORARY TABLE "OrphanedBaseItemIds" ("Id" TEXT NOT NULL PRIMARY KEY); + + INSERT INTO "OrphanedBaseItemIds" ("Id") + WITH RECURSIVE Orphan ("Id") AS ( + SELECT Child."Id" + FROM "BaseItems" AS Child + WHERE Child."ParentId" IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM "BaseItems" AS Parent WHERE Parent."Id" = Child."ParentId") + UNION + SELECT Descendant."Id" + FROM "BaseItems" AS Descendant + INNER JOIN Orphan ON Descendant."ParentId" = Orphan."Id" + ) + SELECT "Id" FROM Orphan; + + -- Keep the play state of the doomed items the way ItemPersistenceService does when it + -- 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. + 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" + WHERE Doomed."UserId" = "UserData"."UserId" + AND Doomed."CustomDataKey" = "UserData"."CustomDataKey"); + + DELETE FROM "UserData" + WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds") + AND "rowid" NOT IN ( + SELECT MIN("rowid") + FROM "UserData" + WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds") + GROUP BY "UserId", "CustomDataKey"); + + UPDATE "UserData" + SET "ItemId" = '00000000-0000-0000-0000-000000000001', + "RetentionDate" = datetime('now') + WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds"); + + -- FK_LinkedChildren_BaseItems_{ParentId,ChildId} are NO ACTION, so these rows have to + -- go by hand or the delete below fails on them. + DELETE FROM "LinkedChildren" + WHERE "ParentId" IN (SELECT "Id" FROM "OrphanedBaseItemIds") + OR "ChildId" IN (SELECT "Id" FROM "OrphanedBaseItemIds"); + + DELETE FROM "BaseItems" WHERE "Id" IN (SELECT "Id" FROM "OrphanedBaseItemIds"); + + DROP TABLE "OrphanedBaseItemIds"; + """); + // Normalize OwnerId to uppercase GUID format migrationBuilder.Sql( @"UPDATE BaseItems diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs index 3d4cf90441..2530e84af6 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs @@ -11,8 +11,8 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations /// <inheritdoc /> protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL;"); - migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL;"); + migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL OR UserId NOT IN (SELECT Id FROM Users);"); + migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL OR UserId NOT IN (SELECT Id FROM Users);"); migrationBuilder.DropIndex( name: "IX_Preferences_UserId_Kind", diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index 8020fe1f93..3330b64b69 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data.Common; using System.Globalization; using System.IO; using System.Linq; @@ -103,17 +104,9 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider } /// <inheritdoc/> - public async Task RunScheduledOptimisation(CancellationToken cancellationToken) + public Task RunScheduledOptimisation(CancellationToken cancellationToken) { - var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await using (context.ConfigureAwait(false)) - { - await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); - _logger.LogInformation("jellyfin.db optimized successfully!"); - } + return OptimizeAsync(cancellationToken); } /// <inheritdoc/> @@ -125,19 +118,109 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider /// <inheritdoc/> public async Task RunShutdownTask(CancellationToken cancellationToken) { + // Run before disposing the application. Only a checkpoint: stopping is on a deadline. + + // Empty the pool first. Anything still parked in it can start reading again between here and the + // checkpoint, and a reader that holds the write-ahead log open is exactly what makes the truncation + // fail. Connections handed out already cannot be taken away, but they get disposed on return. + SqliteConnection.ClearAllPools(); + + try + { + if (DbContextFactory is not null) + { + var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + // A missed checkpoint only leaves a write-ahead log for the next start to replay, so never fail the + // shutdown over this. + _logger.LogError(ex, "Error while checkpointing jellyfin.db"); + } + + // The checkpointing connection went back into the pool, so retire that one as well. + SqliteConnection.ClearAllPools(); + } + + private async Task OptimizeAsync(CancellationToken cancellationToken) + { if (DbContextFactory is null) { return; } - // Run before disposing the application var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); + await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + 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); + + _logger.LogDebug( + "Rebuilding jellyfin.db on disk, scratch space goes to {TempDirectory}", + Environment.GetEnvironmentVariable("SQLITE_TMPDIR") ?? "SQLite's default temporary directory"); + await context.Database.ExecuteSqlRawAsync("PRAGMA temp_store=1", cancellationToken).ConfigureAwait(false); + try + { + await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); + } + finally + { + // The connection goes back to the pool, so hand it over the way it was handed to us. + if (tempStore is not null) + { + await context.Database.ExecuteSqlRawAsync( + FormattableString.Invariant($"PRAGMA temp_store={tempStore.Value}"), + CancellationToken.None).ConfigureAwait(false); + } + } + + await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false); + try + { + await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false); + } + finally + { + if (analysisLimit is not null) + { + await context.Database.ExecuteSqlRawAsync( + FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"), + CancellationToken.None).ConfigureAwait(false); + } + } + + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + _logger.LogInformation("jellyfin.db optimized successfully!"); + } + finally + { + await context.Database.CloseConnectionAsync().ConfigureAwait(false); + } } + } - SqliteConnection.ClearAllPools(); + private static async Task<long?> ReadPragmaValueAsync(DbCommand command, CancellationToken cancellationToken) + { + var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return value is null or DBNull ? null : Convert.ToInt64(value, CultureInfo.InvariantCulture); } /// <inheritdoc/> @@ -147,16 +230,31 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider } /// <inheritdoc /> - public Task<string> MigrationBackupFast(CancellationToken cancellationToken) + public async Task<string> MigrationBackupFast(CancellationToken cancellationToken) { - var key = DateTime.UtcNow.ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture); var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); - var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName); - Directory.CreateDirectory(backupFile); + var backupFolder = Path.Combine(_applicationPaths.DataPath, BackupFolderName); + Directory.CreateDirectory(backupFolder); + + if (DbContextFactory is not null && File.Exists(path)) + { + var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + } + } + + var key = DateTime.UtcNow.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); + var backupFile = Path.Combine(backupFolder, $"{key}_jellyfin.db"); + for (var attempt = 1; File.Exists(backupFile); attempt++) + { + key = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyyMMddHHmmss}_{attempt}"); + backupFile = Path.Combine(backupFolder, $"{key}_jellyfin.db"); + } - backupFile = Path.Combine(backupFile, $"{key}_jellyfin.db"); File.Copy(path, backupFile); - return Task.FromResult(key); + return key; } /// <inheritdoc /> @@ -173,10 +271,55 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider return Task.CompletedTask; } + if (!TryRetireWriteAheadLog(path)) + { + _logger.LogCritical( + "Refusing to restore jellyfin.db: the write-ahead log at {WriteAheadLog} could not be retired, which " + + "means the database is still open and replacing it now would silently bring back the data this " + + "rollback is undoing. Stop the server and copy {Backup} over {Path} by hand.", + path + "-wal", + backupFile, + path); + return Task.CompletedTask; + } + File.Copy(backupFile, path, true); + return Task.CompletedTask; } + private bool TryRetireWriteAheadLog(string path) + { + var writeAheadLogPath = path + "-wal"; + if (!File.Exists(path) || !File.Exists(writeAheadLogPath)) + { + return true; + } + + try + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = path, + Mode = SqliteOpenMode.ReadWrite, + Pooling = false + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + command.ExecuteNonQuery(); + } + catch (SqliteException ex) + { + // Either something else holds the database or it is too damaged to open. The check below covers both. + _logger.LogError(ex, "Could not open jellyfin.db to retire its write-ahead log"); + } + + return !File.Exists(writeAheadLogPath); + } + /// <inheritdoc /> public Task DeleteBackup(string key) { diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 3e353db8de..b8d40614d2 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -1,8 +1,10 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using BlurHashSharp.SkiaSharp; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; @@ -22,22 +24,15 @@ namespace Jellyfin.Drawing.Skia; public class SkiaEncoder : IImageEncoder { private const string SvgFormat = "svg"; + + // The light sharpening kernel applied after resizing, see ResizeImage. + private const float SharpenCenterWeight = 1.4f; + private const float SharpenNeighborWeight = -0.1f; + private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" }; private readonly ILogger<SkiaEncoder> _logger; private readonly IApplicationPaths _appPaths; private static readonly SKTypeface?[] _typefaces = InitializeTypefaces(); - private static readonly SKImageFilter _imageFilter = SKImageFilter.CreateMatrixConvolution( - new SKSizeI(3, 3), - [ - 0, -.1f, 0, - -.1f, 1.4f, -.1f, - 0, -.1f, 0 - ], - 1f, - 0f, - new SKPointI(1, 1), - SKShaderTileMode.Clamp, - true); /// <summary> /// The default sampling options, equivalent to old high quality filter settings when upscaling. @@ -561,8 +556,15 @@ public class SkiaEncoder : IImageEncoder /// <returns>The resized image.</returns> internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false) { - using var surface = SKSurface.Create(targetInfo); - using var canvas = surface.Canvas; + if (source.Width == targetInfo.Width && source.Height == targetInfo.Height) + { + return SKImage.FromBitmap(source); + } + + var isDownscale = source.Width > targetInfo.Width || source.Height > targetInfo.Height; + + using var target = new SKBitmap(targetInfo); + using var canvas = new SKCanvas(target); using var paint = new SKPaint(); paint.IsAntialias = isAntialias; paint.IsDither = isDither; @@ -570,11 +572,10 @@ public class SkiaEncoder : IImageEncoder // Historically, kHigh implied cubic filtering, but only when upsampling. // If specified kHigh, and were down-sampling, Skia used to switch back to kMedium (bilinear filtering plus mipmaps). // With current skia API, passing Mitchell cubic when down-sampling will cause serious quality degradation. - var samplingOptions = source.Width > targetInfo.Width || source.Height > targetInfo.Height + var samplingOptions = isDownscale ? DefaultSamplingOptions : UpscaleSamplingOptions; - paint.ImageFilter = _imageFilter; canvas.DrawBitmap( source, SKRect.Create(0, 0, source.Width, source.Height), @@ -582,7 +583,78 @@ public class SkiaEncoder : IImageEncoder samplingOptions, paint); - return surface.Snapshot(); + if (isDownscale) + { + SharpenInPlace(target); + } + + return SKImage.FromBitmap(target); + } + + /// <summary> + /// Applies the light 3x3 sharpening kernel to the bitmap in place. + /// + /// This is equivalent to the SKImageFilter.CreateMatrixConvolution paint filter that + /// was previously part of the resize draw call. Since the SkiaSharp 3 update that + /// filter no longer has a fast CPU path and takes multiple seconds per image on the + /// software rasterizer, so the same kernel is applied directly instead. + /// </summary> + /// <param name="bitmap">The bitmap to sharpen. Must use a color type with four bytes per pixel; other color types are returned unchanged.</param> + internal static void SharpenInPlace(SKBitmap bitmap) + { + if (bitmap.BytesPerPixel != 4) + { + return; + } + + var width = bitmap.Width; + var height = bitmap.Height; + var stride = bitmap.RowBytes; + var pixels = bitmap.GetPixels(); + if (width == 0 || height == 0 || pixels == IntPtr.Zero) + { + return; + } + + var length = stride * height; + var source = ArrayPool<byte>.Shared.Rent(length); + var result = ArrayPool<byte>.Shared.Rent(length); + try + { + Marshal.Copy(pixels, source, 0, length); + + for (var y = 0; y < height; y++) + { + // The kernel clamps at the edges: out-of-bounds taps reuse the edge pixel. + var row = y * stride; + var up = y == 0 ? row : row - stride; + var down = y == height - 1 ? row : row + stride; + + for (var x = 0; x < width; x++) + { + var col = x * 4; + var left = x == 0 ? col : col - 4; + var right = x == width - 1 ? col : col + 4; + + for (var channel = 0; channel < 4; channel++) + { + var value = (SharpenCenterWeight * source[row + col + channel]) + + (SharpenNeighborWeight * (source[up + col + channel] + + source[down + col + channel] + + source[row + left + channel] + + source[row + right + channel])); + result[row + col + channel] = (byte)Math.Clamp((int)(value + 0.5f), 0, 255); + } + } + } + + Marshal.Copy(result, 0, pixels, length); + } + finally + { + ArrayPool<byte>.Shared.Return(source); + ArrayPool<byte>.Shared.Return(result); + } } /// <inheritdoc/> diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 5518d9b954..bdffecdc8a 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -15,7 +15,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Extensions</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index ed02fe6a1d..256a652fd2 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -372,9 +372,7 @@ namespace Jellyfin.LiveTv.Channels { IEnumerable<MediaSourceInfo> results = GetSavedMediaSources(item); - return results - .Select(i => NormalizeMediaSource(item, i)) - .ToList(); + return NormalizeMediaSources(item, results); } /// <summary> @@ -400,9 +398,7 @@ namespace Jellyfin.LiveTv.Channels results = Enumerable.Empty<MediaSourceInfo>(); } - return results - .Select(i => NormalizeMediaSource(item, i)) - .ToList(); + return NormalizeMediaSources(item, results); } private async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken) @@ -420,11 +416,36 @@ namespace Jellyfin.LiveTv.Channels return list; } - private static MediaSourceInfo NormalizeMediaSource(BaseItem item, MediaSourceInfo info) + private static IReadOnlyList<MediaSourceInfo> NormalizeMediaSources(BaseItem item, IEnumerable<MediaSourceInfo> infos) { - info.RunTimeTicks ??= item.RunTimeTicks; + var list = infos.ToList(); + var itemId = item.Id.ToString("N", CultureInfo.InvariantCulture); + var hasDefaultSource = list.Any(i => string.Equals(i.Id, itemId, StringComparison.OrdinalIgnoreCase)); - return info; + for (var index = 0; index < list.Count; index++) + { + var info = list[index]; + info.RunTimeTicks ??= item.RunTimeTicks; + + if (!string.IsNullOrEmpty(info.Id)) + { + continue; + } + + if (!hasDefaultSource) + { + // The source carrying the item id sorts first and becomes the client's default. + info.Id = itemId; + hasDefaultSource = true; + continue; + } + + // Remaining sources need ids that are distinct but stable, as clients send them back to request playback. + var key = string.IsNullOrEmpty(info.Path) ? index.ToString(CultureInfo.InvariantCulture) : info.Path; + info.Id = (itemId + key).GetMD5().ToString("N", CultureInfo.InvariantCulture); + } + + return list; } private async Task<Channel> GetChannel(IChannel channelInfo, CancellationToken cancellationToken) diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 41520f8789..a11f83f2f8 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -125,12 +125,16 @@ public class GuideManager : IGuideManager { var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService)); - var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); + var (channelIds, programIds, hasErrors) = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); - newChannelIdList.AddRange(idList.Item1); - newProgramIdList.AddRange(idList.Item2); + newChannelIdList.AddRange(channelIds); + newProgramIdList.AddRange(programIds); + + // The channels that failed did not report any programs, so cleaning the database + // would delete every program they provide instead of keeping the previous ones. + cleanDatabase &= !hasErrors; } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } @@ -172,10 +176,12 @@ public class GuideManager : IGuideManager : 7; } - private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken) + private async Task<(List<Guid> ChannelIds, List<Guid> ProgramIds, bool HasErrors)> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken) { progress.Report(10); + var hasErrors = false; + var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) .Select(i => new Tuple<string, ChannelInfo>(service.Name, i)) .ToList(); @@ -195,12 +201,13 @@ public class GuideManager : IGuideManager list.Add(item); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { + hasErrors = true; _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); } @@ -314,12 +321,13 @@ public class GuideManager : IGuideManager }, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { + hasErrors = true; _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); } @@ -330,7 +338,7 @@ public class GuideManager : IGuideManager } progress.Report(100); - return new Tuple<List<Guid>, List<Guid>>(channels, programIds); + return (channels, programIds, hasErrors); } private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index 15e20d6f64..7274a3030c 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -352,9 +352,12 @@ public class ListingsManager : IListingsManager var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml"); try { - File.Delete(xmltvCacheFile); + if (File.Exists(xmltvCacheFile)) + { + File.Delete(xmltvCacheFile); + } } - catch (IOException ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId); } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index c93d1f039c..40ec99e9a5 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -549,46 +549,52 @@ namespace Jellyfin.LiveTv.Listings { var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - var lineups = new List<NameIdPair>(); - if (string.IsNullOrWhiteSpace(token)) { - return lineups; + throw new AuthenticationException("Could not authenticate with Schedules Direct"); } + var lineups = new List<NameIdPair>(); + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location); options.Headers.TryAddWithoutValidation("token", token); - try + var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false); + foreach (HeadendsDto headend in root ?? []) { - var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false); - if (root is not null) + foreach (LineupDto lineup in headend.Lineups ?? []) { - foreach (HeadendsDto headend in root) + lineups.Add(new NameIdPair { - foreach (LineupDto lineup in headend.Lineups) - { - lineups.Add(new NameIdPair - { - Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, - Id = lineup.Uri?[18..] - }); - } - } - } - else - { - _logger.LogInformation("No lineups available"); + Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, + Id = string.IsNullOrWhiteSpace(lineup.Lineup) ? lineup.Uri?.Split('/')[^1] : lineup.Lineup + }); } } - catch (Exception ex) + + if (lineups.Count == 0) { - _logger.LogError(ex, "Error getting headends"); + _logger.LogWarning( + "Schedules Direct has no lineups for country {Country} and postal code {PostalCode}", + country, + location); } return lineups; } + private void ResetErrorState(ListingsProviderInfo info) + { + _accountError = false; + Interlocked.Exchange(ref _lastErrorResponseTicks, 0); + + // Only the account being saved is retried, the tokens of the other accounts stay valid. + if (!string.IsNullOrWhiteSpace(info.Username)) + { + _tokens.TryRemove(info.Username, out _); + } + } + private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) { var username = info.Username; @@ -605,15 +611,19 @@ namespace Jellyfin.LiveTv.Listings return null; } - // Permanent account error — SD is disabled for this server lifetime. + // Account error — SD stays disabled until the provider is saved again or the server restarts. if (_accountError) { + _logger.LogWarning("Skipping Schedules Direct request because of an earlier account error. Save the listings provider again to retry."); + return null; } // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout) if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30) { + _logger.LogWarning("Skipping Schedules Direct request because of a recent login failure. Retrying no earlier than 30 minutes after it."); + return null; } @@ -776,7 +786,7 @@ namespace Jellyfin.LiveTv.Listings return root.Token; } - throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message); + throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + (root?.Message ?? "empty response")); } private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) @@ -992,10 +1002,18 @@ namespace Jellyfin.LiveTv.Listings public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) { + ResetErrorState(info); + if (validateLogin) { ArgumentException.ThrowIfNullOrEmpty(info.Username); ArgumentException.ThrowIfNullOrEmpty(info.Password); + + var token = await GetToken(info, CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token)) + { + throw new AuthenticationException("Could not authenticate with Schedules Direct"); + } } if (validateListings) diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index 0aeb7ad05d..f78d464659 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -27,11 +28,14 @@ namespace Jellyfin.LiveTv.Listings public class XmlTvListingsProvider : IListingsProvider { private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1); + private static readonly TimeSpan _downloadTimeout = TimeSpan.FromMinutes(15); private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<XmlTvListingsProvider> _logger; + private readonly ConcurrentDictionary<string, DateTime> _lastDownloadFailures = new(StringComparer.Ordinal); + public XmlTvListingsProvider( IServerConfigurationManager config, IHttpClientFactory httpClientFactory, @@ -64,32 +68,51 @@ namespace Jellyfin.LiveTv.Listings string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv"); string cacheFile = Path.Join(cacheDir, cacheFilename); - if (File.Exists(cacheFile)) + if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) + { + return cacheFile; + } + + var isRemote = info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase); + + if (isRemote + && _lastDownloadFailures.TryGetValue(info.Path, out var lastFailure) + && DateTime.UtcNow - lastFailure < _maxCacheAge) { - if (File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) + if (File.Exists(cacheFile)) { return cacheFile; } - File.Delete(cacheFile); - } - else - { - Directory.CreateDirectory(cacheDir); + throw new InvalidOperationException("Skipping the XMLTV download after a recent failure: " + info.Path); } + Directory.CreateDirectory(cacheDir); + + var tempFile = cacheFile + ".tmp"; + try { - if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + using var timeout = new CancellationTokenSource(_downloadTimeout); + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + var downloadCancellationToken = linkedTokenSource.Token; + + if (isRemote) { _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + httpClient.Timeout = _downloadTimeout; + + using var response = await httpClient + .GetAsync(info.Path, HttpCompletionOption.ResponseHeadersRead, downloadCancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path; - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(downloadCancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { - return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false); + await UnzipIfNeededAndCopy(redirectedUrl, stream, tempFile, downloadCancellationToken).ConfigureAwait(false); } } else @@ -97,28 +120,63 @@ namespace Jellyfin.LiveTv.Listings var stream = AsyncFile.OpenRead(info.Path); await using (stream.ConfigureAwait(false)) { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + await UnzipIfNeededAndCopy(info.Path, stream, tempFile, downloadCancellationToken).ConfigureAwait(false); } } + + File.Move(tempFile, cacheFile, true); + _lastDownloadFailures.TryRemove(info.Path, out _); + + return cacheFile; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TryDeleteTempFile(tempFile); + + throw; } catch (Exception ex) { + TryDeleteTempFile(tempFile); + _lastDownloadFailures[info.Path] = DateTime.UtcNow; + _logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path); if (File.Exists(cacheFile)) { - File.Delete(cacheFile); + _logger.LogWarning("Falling back to the previously downloaded XMLTV file for {Path}", info.Path); + + return cacheFile; + } + + if (ex is OperationCanceledException) + { + throw new TimeoutException( + string.Format(CultureInfo.InvariantCulture, "Timed out downloading the XMLTV file from {0}", info.Path), + ex); } throw; } } - private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) + private void TryDeleteTempFile(string tempFile) + { + try + { + File.Delete(tempFile); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Error deleting temporary XMLTV file {File}", tempFile); + } + } + + private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) { var fileStream = new FileStream( file, - FileMode.CreateNew, + FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, @@ -148,15 +206,8 @@ namespace Jellyfin.LiveTv.Listings var fileInfo = new FileInfo(file); if (!fileInfo.Exists || fileInfo.Length == 0) { - if (fileInfo.Exists) - { - File.Delete(file); - } - throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl); } - - return file; } public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) @@ -281,6 +332,13 @@ namespace Jellyfin.LiveTv.Listings public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) { + // Saving the provider is an explicit retry, so the download backoff has to be dropped + // together with the cached file the listings manager deletes. + if (!string.IsNullOrEmpty(info.Path)) + { + _lastDownloadFailures.TryRemove(info.Path, out _); + } + // Assume all urls are valid. check files for existence if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path)) { diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 2edf7681db..93f9083178 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -1229,6 +1229,14 @@ namespace Jellyfin.LiveTv .Where(IsLiveTvEnabled); } + /// <inheritdoc /> + public bool IsEnabledForUser(User user) + { + ArgumentNullException.ThrowIfNull(user); + + return IsLiveTvEnabled(user); + } + /// <summary> /// Resets the tuner. /// </summary> diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs index 62a06370da..8a5aeccba5 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -286,7 +286,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable if (requiresRefresh) { - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } } diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs index fb606be0ef..902ca76af8 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs @@ -32,6 +32,7 @@ namespace Jellyfin.LiveTv.TunerHosts { private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"]; private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"]; + private static readonly string[] _manifestExtensions = [".m3u8", ".m3u", ".mpd"]; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerApplicationHost _appHost; @@ -151,11 +152,20 @@ namespace Jellyfin.LiveTv.TunerHosts var protocol = _mediaSourceManager.GetPathProtocol(path); var isRemote = true; - if (Uri.TryCreate(path, UriKind.Absolute, out var uri)) + Uri.TryCreate(path, UriKind.Absolute, out var uri); + if (uri is not null) { isRemote = !_networkManager.IsInLocalNetwork(uri.Host); } + // A manifest is not a byte stream. Serving one directly hands the client a playlist whose + // variant and segment URIs are relative to the origin, and those do not resolve against the + // Jellyfin url the client fetched it from. Remux or transcode these instead. + if (IsManifest(path, uri)) + { + supportsDirectPlay = false; + } + var httpHeaders = new Dictionary<string, string>(); if (protocol == MediaProtocol.Http) @@ -210,6 +220,20 @@ namespace Jellyfin.LiveTv.TunerHosts return mediaSource; } + /// <summary> + /// Determines whether a channel path points at an HLS or DASH manifest rather than at a byte stream. + /// </summary> + /// <param name="path">The channel path.</param> + /// <param name="uri">The channel path parsed as an absolute uri, or <c>null</c> if it is not one.</param> + /// <returns><c>true</c> if the path names a streaming manifest.</returns> + private static bool IsManifest(string path, Uri uri) + { + // Use the uri path when there is one so that a query string does not hide the extension. + var extension = Path.GetExtension(uri is null ? path : uri.AbsolutePath); + + return _manifestExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); + } + public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken) { return Task.FromResult(new List<TunerHostInfo>()); diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs index 1f59908a86..e57fbfe473 100644 --- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs +++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.IO; using System.Linq; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -8,29 +10,31 @@ namespace Jellyfin.Controller.Tests { public class DirectoryServiceTests { - private const string LowerCasePath = "/music/someartist"; - private const string UpperCasePath = "/music/SOMEARTIST"; + // Path.GetDirectoryName, which Invalidate uses to find the parent, normalizes the + // separators, so cache keys only match the parent it returns when they use the platform's. + private static readonly string _lowerCasePath = LocalPath("/music/someartist"); + private static readonly string _upperCasePath = LocalPath("/music/SOMEARTIST"); private static readonly FileSystemMetadata[] _lowerCaseFileSystemMetadata = { new() { - FullName = LowerCasePath + "/Artwork", + FullName = Path.Combine(_lowerCasePath, "Artwork"), IsDirectory = true }, new() { - FullName = LowerCasePath + "/Some Other Folder", + FullName = Path.Combine(_lowerCasePath, "Some Other Folder"), IsDirectory = true }, new() { - FullName = LowerCasePath + "/Song 2.mp3", + FullName = Path.Combine(_lowerCasePath, "Song 2.mp3"), IsDirectory = false }, new() { - FullName = LowerCasePath + "/Song 3.mp3", + FullName = Path.Combine(_lowerCasePath, "Song 3.mp3"), IsDirectory = false } }; @@ -39,12 +43,12 @@ namespace Jellyfin.Controller.Tests { new() { - FullName = UpperCasePath + "/Lyrics", + FullName = Path.Combine(_upperCasePath, "Lyrics"), IsDirectory = true }, new() { - FullName = UpperCasePath + "/Song 1.mp3", + FullName = Path.Combine(_upperCasePath, "Song 1.mp3"), IsDirectory = false } }; @@ -53,12 +57,12 @@ namespace Jellyfin.Controller.Tests public void GetFileSystemEntries_GivenPathsWithDifferentCasing_CachesAll() { var fileSystemMock = new Mock<IFileSystem>(); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); var directoryService = new DirectoryService(fileSystemMock.Object); - var upperCaseResult = directoryService.GetFileSystemEntries(UpperCasePath); - var lowerCaseResult = directoryService.GetFileSystemEntries(LowerCasePath); + var upperCaseResult = directoryService.GetFileSystemEntries(_upperCasePath); + var lowerCaseResult = directoryService.GetFileSystemEntries(_lowerCasePath); Assert.Equal(_upperCaseFileSystemMetadata, upperCaseResult); Assert.Equal(_lowerCaseFileSystemMetadata, lowerCaseResult); @@ -68,12 +72,12 @@ namespace Jellyfin.Controller.Tests public void GetFiles_GivenPathsWithDifferentCasing_ReturnsCorrectFiles() { var fileSystemMock = new Mock<IFileSystem>(); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); var directoryService = new DirectoryService(fileSystemMock.Object); - var upperCaseResult = directoryService.GetFiles(UpperCasePath); - var lowerCaseResult = directoryService.GetFiles(LowerCasePath); + var upperCaseResult = directoryService.GetFiles(_upperCasePath); + var lowerCaseResult = directoryService.GetFiles(_lowerCasePath); Assert.Equal(_upperCaseFileSystemMetadata.Where(f => !f.IsDirectory), upperCaseResult); Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => !f.IsDirectory), lowerCaseResult); @@ -83,12 +87,12 @@ namespace Jellyfin.Controller.Tests public void GetDirectories_GivenPathsWithDifferentCasing_ReturnsCorrectDirectories() { var fileSystemMock = new Mock<IFileSystem>(); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); var directoryService = new DirectoryService(fileSystemMock.Object); - var upperCaseResult = directoryService.GetDirectories(UpperCasePath); - var lowerCaseResult = directoryService.GetDirectories(LowerCasePath); + var upperCaseResult = directoryService.GetDirectories(_upperCasePath); + var lowerCaseResult = directoryService.GetDirectories(_lowerCasePath); Assert.Equal(_upperCaseFileSystemMetadata.Where(f => f.IsDirectory), upperCaseResult); Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => f.IsDirectory), lowerCaseResult); @@ -248,5 +252,171 @@ namespace Jellyfin.Controller.Tests Assert.Equal(cachedPaths, result); Assert.Equal(newPaths, secondResult); } + + [Fact] + public void GetFileSystemEntries_RepeatedPath_ReadsTheFileSystemOnce() + { + var fileSystemMock = new Mock<IFileSystem>(MockBehavior.Strict); + fileSystemMock.Setup(f => f.GetFileSystemEntries(_lowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata); + + var directoryService = new DirectoryService(fileSystemMock.Object); + + directoryService.GetFileSystemEntries(_lowerCasePath); + directoryService.GetFileSystemEntries(_lowerCasePath); + + fileSystemMock.Verify(f => f.GetFileSystemEntries(_lowerCasePath), Times.Once); + } + + [Fact] + public void Invalidate_GivenADirectory_DropsBothTheListingAndTheFilePaths() + { + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata) + .Returns(_upperCaseFileSystemMetadata); + fileSystemMock.SetupSequence(f => f.GetFilePaths(_lowerCasePath, false)) + .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") }) + .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3"), Path.Combine(_lowerCasePath, "Song 2.srt") }); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(_lowerCasePath); + directoryService.GetFilePaths(_lowerCasePath); + + directoryService.Invalidate(_lowerCasePath); + + Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath)); + Assert.Equal(2, directoryService.GetFilePaths(_lowerCasePath).Count); + } + + [Fact] + public void Invalidate_GivenAFile_DropsTheListingOfTheDirectoryHoldingIt() + { + var newFile = Path.Combine(_lowerCasePath, "Song 2.srt"); + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata) + .Returns(_upperCaseFileSystemMetadata); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(_lowerCasePath); + + directoryService.Invalidate(newFile); + + Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath)); + } + + [Fact] + public void GetFilePaths_ClearingTheCache_KeepsTheParentDirectory() + { + var parentPath = LocalPath("/music"); + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFilePaths(_lowerCasePath)) + .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") }); + fileSystemMock.Setup(f => f.GetFileSystemEntries(parentPath)) + .Returns(_lowerCaseFileSystemMetadata); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(parentPath); + + directoryService.GetFilePaths(_lowerCasePath, true); + + directoryService.GetFileSystemEntries(parentPath); + fileSystemMock.Verify(f => f.GetFileSystemEntries(parentPath), Times.Once); + } + + [Fact] + public void GetFileSystemEntries_MoreRecordsThanTheCeiling_DropsCache() + { + // Charged by the files in a listing, not the number of listings, so a few big folders + // reach the limit where a lot of small ones would not. + const int FolderCount = 60; + var bigListing = new FileSystemMetadata[5000]; + for (var i = 0; i < bigListing.Length; i++) + { + bigListing[i] = new FileSystemMetadata + { + FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture), + IsDirectory = false + }; + } + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>())) + .Returns(bigListing); + + var directoryService = new DirectoryService(fileSystemMock.Object); + + const string FirstPath = "/music/artist0"; + directoryService.GetFileSystemEntries(FirstPath); + + for (var i = 1; i < FolderCount; i++) + { + directoryService.GetFileSystemEntries("/music/artist" + i.ToString(CultureInfo.InvariantCulture)); + } + + directoryService.GetFileSystemEntries(FirstPath); + + fileSystemMock.Verify(f => f.GetFileSystemEntries(FirstPath), Times.Exactly(2)); + } + + [Fact] + public void GetFileSystemEntries_RepeatedlyInvalidatedFolder_KeepsUnrelatedEntriesCached() + { + // Invalidating gives the records back, so churning one folder must not add up to the + // ceiling and drop everything else with it. + const int ChurnCount = 50; + var bigListing = new FileSystemMetadata[5000]; + for (var i = 0; i < bigListing.Length; i++) + { + bigListing[i] = new FileSystemMetadata + { + FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture), + IsDirectory = false + }; + } + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>())) + .Returns(bigListing); + + var directoryService = new DirectoryService(fileSystemMock.Object); + + const string ChurnedPath = "/music/watched"; + const string StablePath = "/music/untouched"; + directoryService.GetFileSystemEntries(StablePath); + + for (var i = 0; i < ChurnCount; i++) + { + directoryService.GetFileSystemEntries(ChurnedPath); + directoryService.Invalidate(ChurnedPath); + } + + directoryService.GetFileSystemEntries(StablePath); + + fileSystemMock.Verify(f => f.GetFileSystemEntries(StablePath), Times.Once); + } + + [Fact] + public void GetFileSystemEntry_MissingPath_IsNotRemembered() + { + const string MissingPath = "/music/not-here"; + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemInfo(MissingPath)) + .Returns(new FileSystemMetadata { FullName = MissingPath, Exists = false }) + .Returns(new FileSystemMetadata { FullName = MissingPath, Exists = true }); + + var directoryService = new DirectoryService(fileSystemMock.Object); + + Assert.Null(directoryService.GetFileSystemEntry(MissingPath)); + + Assert.NotNull(directoryService.GetFileSystemEntry(MissingPath)); + } + + private static string LocalPath(string path) + => path.Replace('/', Path.DirectorySeparatorChar); } } diff --git a/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs new file mode 100644 index 0000000000..272c434fe9 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +[Collection("LibraryManagerTests")] +public class AggregateFolderTests +{ + [Fact] + public void Children_ClearedAfterALibraryWasAdded_ListsTheNewLibrary() + { + var existing = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" }; + var added = new Folder { Id = Guid.NewGuid(), Path = "/libraries/collections" }; + + // What the repository holds grows once the new library has been resolved and stored. + var stored = new List<BaseItem> { existing }; + + var itemRepository = new Mock<IItemRepository>(); + itemRepository.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(() => stored.ToList()); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetItemById(It.IsAny<Guid>())) + .Returns((Guid id) => stored.Find(i => i.Id.Equals(id))); + + BaseItem.ItemRepository = itemRepository.Object; + BaseItem.LibraryManager = libraryManager.Object; + + var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" }; + + Assert.Equal([existing.Id], root.Children.Select(i => i.Id)); + + stored.Add(added); + root.Children = null; + + // Null-forgiving: the setter takes null to mean "drop the cache", the getter reloads. + Assert.Equal([existing.Id, added.Id], root.Children!.Select(i => i.Id)); + } + + [Fact] + public void Children_AssignedASet_KeepsThatSet() + { + var itemRepository = new Mock<IItemRepository>(MockBehavior.Strict); + BaseItem.ItemRepository = itemRepository.Object; + + var assigned = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" }; + var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" }; + + root.Children = [assigned]; + + // Never goes to the repository, so the strict mock stays unused. + Assert.Equal([assigned.Id], root.Children.Select(i => i.Id)); + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index 86bac4256a..e072bccb82 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -26,8 +27,63 @@ using Xunit; namespace Jellyfin.Controller.Tests.Entities; +[Collection("LibraryManagerTests")] public class BaseItemTests { + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task ValidateChildren_FailedEnumeration_DoesNotReconcileOrDeleteChildren(bool failAfterFirstChild, bool accessDenied) + { + var previousLibrary = BaseItem.LibraryManager; + var previousRepository = BaseItem.ItemRepository; + var previousLogger = BaseItem.Logger; + 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); + try + { + BaseItem.LibraryManager = library.Object; + BaseItem.ItemRepository = repository.Object; + BaseItem.Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<BaseItem>.Instance; + var folder = new FailingEnumerationFolder(failAfterFirstChild, accessDenied) + { + Id = Guid.NewGuid(), + Path = "/media/review-folder" + }; + await folder.ValidateChildren(new Progress<double>(), new MetadataRefreshOptions(directory.Object), recursive: false, cancellationToken: TestContext.Current.CancellationToken).ConfigureAwait(true); + Assert.True(folder.EnumerationAttempted); + repository.VerifyNoOtherCalls(); + library.VerifyNoOtherCalls(); + } + finally + { + BaseItem.LibraryManager = previousLibrary; + BaseItem.ItemRepository = previousRepository; + BaseItem.Logger = previousLogger; + } + } + + [Fact] + public void SetPrimaryVersionId_Null_RestoresTheItemsOwnPresentationKey() + { + var primaryId = Guid.NewGuid(); + var video = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv" }; + + // While it is a version, it presents as the primary so lists collapse the two together. + video.SetPrimaryVersionId(primaryId); + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey); + + // Promoting it back has to restore its own key, or it keeps collapsing onto - and staying + // hidden behind - a primary it no longer belongs to. + video.SetPrimaryVersionId(null); + Assert.Null(video.PrimaryVersionId); + Assert.Equal(video.Id.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey); + } + [Fact] public void GetItemByNameFolderName_ShortName_IsKeptAsIs() { @@ -185,6 +241,17 @@ public class BaseItemTests "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]", "[Final Cut] [1080p HEVC AAC]", "[EE by ADM] [480p HEVC AAC]")] + // Numeric version labels: the dot between the digits is a decimal point, not a delimiter, so the + // prefix retreats past it to the '-' instead of leaving "0" / "11". + [InlineData( + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11", + "1.0", + "1.11")] + // Numeric labels with no structural delimiter at all fall back to the space boundary. + [InlineData("Movie (2007) 1.0", "Movie (2007) 1.11", "1.0", "1.11")] + // A dot followed by a non-digit is still a delimiter, even after a digit. + [InlineData("Movie - Part 1.HDR", "Movie - Part 1.SDR", "HDR", "SDR")] public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt) { var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv"; @@ -216,6 +283,24 @@ public class BaseItemTests } [Fact] + public void GetCommonVersionPrefix_NumericLabels_KeepsWholeNumber() + { + // Three versions labelled "1.0", "1.01" and "1.11": the common prefix stops inside the version + // number, so it must retreat past the decimal point to the '-' delimiter. + string[] fileNames = + [ + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.01", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11" + ]; + + var prefix = BaseItem.GetCommonVersionPrefix(fileNames); + + Assert.Equal("Evangelion 1.0 You Are (Not) Alone (2007) -", prefix); + Assert.Equal(["1.0", "1.01", "1.11"], fileNames.Select(n => n[prefix.Length..].TrimStart(' '))); + } + + [Fact] public void GetAlternateVersion_ReturnsMatchingLocalVersion() { var (primary, alt1, alt2) = SetupVersionGroup(); @@ -644,4 +729,25 @@ public class BaseItemTests Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids); } + + private sealed class FailingEnumerationFolder(bool failAfterFirstChild, bool accessDenied) : Folder + { + public bool EnumerationAttempted { get; private set; } + + protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService) + { + EnumerationAttempted = true; + if (failAfterFirstChild) + { + yield return new Movie { Id = Guid.NewGuid(), Path = "/media/review-folder/movie.mkv" }; + } + + if (accessDenied) + { + throw new System.Security.SecurityException("Simulated access failure"); + } + + throw new IOException("Simulated directory read failure"); + } + } } diff --git a/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs new file mode 100644 index 0000000000..705238317a --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +/// <summary> +/// Covers <see cref="Folder.ReleaseCachedChildren"/>, which a recursive scan calls as it unwinds so +/// the folders it walked do not keep the whole item graph of the library alive behind it. +/// </summary> +public class FolderChildCacheTests +{ + [Fact] + public void ReleaseCachedChildren_MakesTheNextAccessReload() + { + var folder = new TrackingFolder(); + + Assert.Empty(folder.Children); + Assert.Equal(1, folder.LoadCount); + + // Second access is served from the cache on the instance. + Assert.Empty(folder.Children); + Assert.Equal(1, folder.LoadCount); + + folder.ReleaseCachedChildren(); + + Assert.Empty(folder.Children); + Assert.Equal(2, folder.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_ReachesEveryLevelBelow() + { + var leaf = new TrackingFolder(); + var middle = new TrackingFolder { Source = [leaf] }; + var root = new TrackingFolder { Source = [middle] }; + + // Walk the whole tree, as a recursive scan does, so every level holds its children. + Assert.Single(root.Children); + Assert.Single(middle.Children); + Assert.Empty(leaf.Children); + Assert.Equal(1, root.LoadCount); + Assert.Equal(1, middle.LoadCount); + Assert.Equal(1, leaf.LoadCount); + + root.ReleaseCachedChildren(); + + Assert.Single(root.Children); + Assert.Single(middle.Children); + Assert.Empty(leaf.Children); + Assert.Equal(2, root.LoadCount); + Assert.Equal(2, middle.LoadCount); + Assert.Equal(2, leaf.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_LoadsNothingThatIsNotAlreadyHeld() + { + var leaf = new TrackingFolder(); + var root = new TrackingFolder { Source = [leaf] }; + + root.ReleaseCachedChildren(); + + Assert.Equal(0, root.LoadCount); + Assert.Equal(0, leaf.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_TerminatesOnACycle() + { + var first = new TrackingFolder(); + var second = new TrackingFolder { Source = [first] }; + first.Source = [second]; + + Assert.Single(first.Children); + Assert.Single(second.Children); + + // Clearing before descending is what stops this from recursing forever. + first.ReleaseCachedChildren(); + + Assert.Equal(1, first.LoadCount); + Assert.Equal(1, second.LoadCount); + } + + private sealed class TrackingFolder : Folder + { + public int LoadCount { get; private set; } + + public IReadOnlyList<BaseItem> Source { get; set; } = []; + + protected override IReadOnlyList<BaseItem> LoadChildren() + { + LoadCount++; + return Source; + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs new file mode 100644 index 0000000000..70da5eafe5 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Linq; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Entities; +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 +{ + [Fact] + public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden() + { + // The SQL parental filter hides a container whose every member is blocked, so a listing + // built in memory has to reach the same answer. + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + SetupLibrary(blocked); + + Assert.False(BuildPlaylist(blocked).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_PlaylistWithOneAllowedItem_StaysVisible() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var allowed = new Audio { Id = Guid.NewGuid(), Name = "Song" }; + SetupLibrary(blocked, allowed); + + Assert.True(BuildPlaylist(blocked, allowed).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_UnrestrictedUser_LeavesTheItemsUnresolved() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var libraryManager = SetupLibrary(blocked); + var user = new User("user", "auth-provider", "reset-provider"); + + Assert.True(BuildPlaylist(blocked).IsVisible(user)); + + // Resolving a playlist's items is a query per playlist; nothing may run it for a user no + // rating keeps anything from. + libraryManager.Verify(x => x.GetItemList(It.IsAny<InternalItemsQuery>()), Times.Never); + } + + private static Mock<ILibraryManager> SetupLibrary(params BaseItem[] items) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(items); + BaseItem.LibraryManager = libraryManager.Object; + + return libraryManager; + } + + private static Playlist BuildPlaylist(params BaseItem[] items) + { + // An empty path keeps the playlist out of the shared-playlist branch. + return new Playlist + { + Id = Guid.NewGuid(), + Name = "Playlist", + LinkedChildren = items.Select(LinkedChild.Create).ToArray() + }; + } + + private static User BuildRestrictedUser() + { + var user = new User("user", "auth-provider", "reset-provider") { MaxParentalRatingScore = 5 }; + user.SetPreference(PreferenceKind.BlockUnratedItems, new[] { UnratedItem.Movie }); + + return user; + } +} diff --git a/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs new file mode 100644 index 0000000000..686d839f4f --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.LibraryTaskScheduler; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.LibraryTaskScheduler +{ + public class LimitedConcurrencyLibrarySchedulerTests + { + private static readonly TimeSpan _shortGracePeriod = TimeSpan.FromMilliseconds(50); + + // Generous, because these only ever wait for something that should already have happened. + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10); + + [Fact] + public async Task Enqueue_ProcessesEveryItem() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping); + await using (scheduler) + { + var data = Enumerable.Range(0, 100).ToArray(); + var processed = new ConcurrentBag<int>(); + + await scheduler.Enqueue( + data, + (item, _) => + { + processed.Add(item); + return Task.CompletedTask; + }, + new Progress<double>(), + CancellationToken.None); + + Assert.Equal(data, processed.Order()); + } + } + + [Fact] + public async Task Enqueue_WithFailingWorker_StillCompletes() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping); + await using (scheduler) + { + await scheduler.Enqueue( + Enumerable.Range(0, 20).ToArray(), + (item, _) => item % 2 == 0 ? throw new InvalidOperationException("boom") : Task.CompletedTask, + new Progress<double>(), + CancellationToken.None); + } + } + + /// <summary> + /// The runners wait on a source linked to <see cref="IHostApplicationLifetime.ApplicationStopping"/>, + /// so a shutdown has to reach them. It does not travel from the linked source back to the one + /// the cleanup cancels, which is what made them immortal. + /// </summary> + /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> + [Fact] + public async Task ApplicationStopping_RetiresRunners() + { + using var appStopping = new CancellationTokenSource(); + + // Long enough that the cleanup cannot be what retires them. + var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5)); + await using (scheduler) + { + await RunOneOperation(scheduler); + Assert.True(scheduler.ActiveRunnerCount > 0); + + await appStopping.CancelAsync(); + + await WaitForAsync(() => scheduler.ActiveRunnerCount == 0); + } + } + + /// <summary> + /// The cleanup used to be a one shot: it never released the scheduling slot it took, so + /// every runner spawned after the first pass stayed around for the lifetime of the server. + /// </summary> + /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> + [Fact] + public async Task Enqueue_RetiresIdleRunnersAfterEveryOperation() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping); + await using (scheduler) + { + for (var round = 0; round < 3; round++) + { + await RunOneOperation(scheduler); + Assert.True(scheduler.ActiveRunnerCount > 0, $"no runner spawned in round {round}"); + + await WaitForAsync(() => scheduler.ActiveRunnerCount == 0); + } + } + } + + /// <summary> + /// Disposing used to sit out the rest of the cleanup grace period, holding up shutdown for + /// up to a minute. + /// </summary> + /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> + [Fact] + public async Task DisposeAsync_DoesNotWaitOutTheGracePeriod() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5)); + + await RunOneOperation(scheduler); + + var stopwatch = Stopwatch.StartNew(); + await scheduler.DisposeAsync(); + + Assert.True(stopwatch.Elapsed < _timeout, $"disposing took {stopwatch.Elapsed}"); + } + + [Fact] + public async Task Enqueue_AfterDispose_DoesNothing() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping); + await scheduler.DisposeAsync(); + + var processed = 0; + await scheduler.Enqueue( + Enumerable.Range(0, 10).ToArray(), + (_, _) => + { + Interlocked.Increment(ref processed); + return Task.CompletedTask; + }, + new Progress<double>(), + CancellationToken.None); + + Assert.Equal(0, processed); + } + + [Theory] + [InlineData(1)] + [InlineData(4)] + public async Task Enqueue_FromWithinAWorker_DoesNotDeadlock(int fanout) + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping, fanout: fanout); + await using (scheduler) + { + var inner = 0; + + var outer = scheduler.Enqueue( + Enumerable.Range(0, 8).ToArray(), + (_, _) => scheduler.Enqueue( + Enumerable.Range(0, 4).ToArray(), + (_, _) => + { + Interlocked.Increment(ref inner); + return Task.CompletedTask; + }, + new Progress<double>(), + CancellationToken.None), + new Progress<double>(), + CancellationToken.None); + + await outer.WaitAsync(_timeout, TestContext.Current.CancellationToken); + + Assert.Equal(32, inner); + } + } + + private static LimitedConcurrencyLibraryScheduler CreateScheduler( + CancellationTokenSource appStopping, + int fanout = 4, + TimeSpan? gracePeriod = null) + { + var lifetime = new Mock<IHostApplicationLifetime>(); + lifetime.SetupGet(x => x.ApplicationStopping).Returns(() => appStopping.Token); + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager.SetupGet(x => x.Configuration) + .Returns(new ServerConfiguration { LibraryScanFanoutConcurrency = fanout }); + + return new LimitedConcurrencyLibraryScheduler( + lifetime.Object, + NullLogger<LimitedConcurrencyLibraryScheduler>.Instance, + configurationManager.Object, + gracePeriod ?? _shortGracePeriod); + } + + private static Task RunOneOperation(LimitedConcurrencyLibraryScheduler scheduler) + => scheduler.Enqueue( + Enumerable.Range(0, 8).ToArray(), + (_, _) => Task.CompletedTask, + new Progress<double>(), + CancellationToken.None); + + private static async Task WaitForAsync(Func<bool> condition) + { + var stopwatch = Stopwatch.StartNew(); + while (!condition()) + { + Assert.True(stopwatch.Elapsed < _timeout, "timed out waiting for the scheduler to settle"); + await Task.Delay(20, TestContext.Current.CancellationToken); + } + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs new file mode 100644 index 0000000000..557035e2d1 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs @@ -0,0 +1,162 @@ +using System; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using Moq; +using Xunit; + +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; + +namespace Jellyfin.Controller.Tests.MediaEncoding; + +public class EncodingHelperDoviTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("bt709", false)] + [InlineData("unknown", false)] + [InlineData("bt2020-10", false)] + [InlineData("smpte2084", true)] + [InlineData("arib-std-b67", true)] + public void GetSwVidFilterChain_InvalidDovi_OnlyTonemapsHdrBaseLayer(string? transfer, bool tonemap) + { + var state = CreateState("hevc", transfer); + var helper = CreateHelper(true); + + var (filters, _, _) = helper.GetSwVidFilterChain(state, new EncodingOptions(), "libx264"); + var args = string.Join(',', filters); + + Assert.Equal(VideoRangeType.DOVIInvalid, state.VideoStream.VideoRangeType); + Assert.Equal(tonemap, args.Contains("tonemapx=", StringComparison.Ordinal)); + Assert.Contains(tonemap ? "color_trc=" + transfer : "color_trc=bt709", args, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null, false)] + [InlineData("bt709", false)] + [InlineData("arib-std-b67", false)] + [InlineData("smpte2084", true)] + [InlineData("SMPTE2084", true)] + public void IsDoviWithHdr10Bl_InvalidDovi_RequiresPq(string? transfer, bool expected) + { + var stream = CreateState("hevc", transfer).VideoStream; + + Assert.True(EncodingHelper.IsDovi(stream)); + Assert.Equal(expected, EncodingHelper.IsDoviWithHdr10Bl(stream)); + } + + [Theory] + [InlineData("hevc", null, "hevc_metadata=remove_dovi=1")] + [InlineData("hevc", "bt709", "hevc_metadata=remove_dovi=1")] + [InlineData("hevc", "smpte2084", "hevc_metadata=remove_dovi=1")] + [InlineData("hevc", "arib-std-b67", "hevc_metadata=remove_dovi=1")] + [InlineData("av1", null, "av1_metadata=remove_dovi=1")] + [InlineData("av1", "bt709", "av1_metadata=remove_dovi=1")] + [InlineData("av1", "smpte2084", "av1_metadata=remove_dovi=1")] + [InlineData("av1", "arib-std-b67", "av1_metadata=remove_dovi=1")] + public void GetBitStreamArgs_InvalidDovi_PreservesClientDependentRemoval(string codec, string? transfer, string expected) + { + var state = CreateState(codec, transfer); + var helper = CreateHelper(true); + + foreach (var (requestedRanges, removeDovi) in new[] { (null, false), ("SDR", false), ("HDR10", false), ("DOVIWithEL", false), ("DOVI", true), ("SDR,DOVI", true) }) + { + state.BaseRequest.VideoRangeType = requestedRanges; + + Assert.Equal(removeDovi, helper.IsDoviRemoved(state)); + if (removeDovi) + { + Assert.Contains(expected, helper.GetBitStreamArgs(state, MediaStreamType.Video), StringComparison.Ordinal); + } + else + { + Assert.Equal(codec == "hevc" ? "-bsf:v hevc_mp4toannexb" : null, helper.GetBitStreamArgs(state, MediaStreamType.Video)); + } + + Assert.False(CreateHelper(false).IsDoviRemoved(state)); + } + } + + [Theory] + [InlineData(null, true)] + [InlineData("HDR10", true)] + [InlineData("DOVI", false)] + [InlineData("SDR,DOVI", false)] + public void CanStreamCopyVideo_InvalidDovi_RequiresRemovalSupportOnlyForDoviClients(string? requestedRanges, bool copyWithoutRemovalSupport) + { + foreach (var codec in new[] { "hevc", "av1" }) + { + foreach (var transfer in new[] { "bt709", "smpte2084" }) + { + var state = CreateState(codec, transfer); + state.BaseRequest.VideoRangeType = requestedRanges; + + Assert.True(CreateHelper(true).CanStreamCopyVideo(state, state.VideoStream)); + Assert.Equal(copyWithoutRemovalSupport, CreateHelper(false).CanStreamCopyVideo(state, state.VideoStream)); + } + } + } + + [Fact] + public void GetBitStreamArgs_ValidDovi_PreservesMetadata() + { + var state = CreateState("hevc", "smpte2084"); + state.VideoStream.ColorSpace = "bt2020nc"; + state.VideoStream.ColorPrimaries = "bt2020"; + state.BaseRequest.VideoRangeType = "DOVIWithEL"; + var helper = CreateHelper(true); + + Assert.False(helper.IsDoviRemoved(state)); + Assert.Equal("-bsf:v hevc_mp4toannexb", helper.GetBitStreamArgs(state, MediaStreamType.Video)); + } + + private static EncodingJobInfo CreateState(string codec, string? transfer) + { + var stream = new MediaStream + { + Type = MediaStreamType.Video, + Codec = codec, + Width = 1920, + Height = 1080, + BitDepth = 10, + DvProfile = codec == "hevc" ? 7 : 10, + DvBlSignalCompatibilityId = codec == "hevc" ? 6 : 1, + RpuPresentFlag = 1, + BlPresentFlag = 1, + ColorSpace = "bt709", + ColorPrimaries = "bt709", + ColorTransfer = transfer + }; + + return new EncodingJobInfo(TranscodingJobType.Hls) + { + VideoStream = stream, + MediaSource = new MediaSourceInfo { Container = "mkv", MediaStreams = [stream] }, + BaseRequest = new VideoRequestDto(), + OutputVideoCodec = "copy", + IsVideoRequest = true, + IsInputVideo = true + }; + } + + private static EncodingHelper CreateHelper(bool supportsRemoval) + { + var encoder = new Mock<IMediaEncoder>(); + encoder.Setup(x => x.SupportsBitStreamFilterWithOption(It.IsAny<BitStreamFilterOptionType>())).Returns(supportsRemoval); + encoder.Setup(x => x.SupportsFilter("tonemapx")).Returns(true); + encoder.SetupGet(x => x.EncoderVersion).Returns(new Version(8, 1)); + + return new EncodingHelper( + Mock.Of<IApplicationPaths>(), + encoder.Object, + Mock.Of<ISubtitleEncoder>(), + Mock.Of<IConfiguration>(), + Mock.Of<IConfigurationManager>(), + Mock.Of<IPathManager>()); + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs new file mode 100644 index 0000000000..586db2dd50 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs @@ -0,0 +1,40 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using Moq; +using Xunit; +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; + +namespace Jellyfin.Controller.Tests.MediaEncoding; + +public class EncodingHelperInferAudioCodecTests +{ + [Theory] + // Manifests and other containers that carry no inferable audio codec. + [InlineData("m3u8", "aac")] + [InlineData("mpd", "aac")] + [InlineData("wtv", "aac")] + [InlineData("", "aac")] + // Containers with a well known audio codec. + [InlineData("mp4", "aac")] + [InlineData("mkv", "aac")] + [InlineData("webm", "opus")] + [InlineData("ts", "mp3")] + // Containers named after the codec they carry. + [InlineData("flac", "flac")] + [InlineData("opus", "opus")] + [InlineData("ac3", "ac3")] + public void InferAudioCodec_ReturnsAnAudioCodec(string container, string expected) + { + Assert.Equal(expected, Create().InferAudioCodec(container)); + } + + private static EncodingHelper Create() + => new( + Mock.Of<IApplicationPaths>(), + Mock.Of<IMediaEncoder>(), + Mock.Of<ISubtitleEncoder>(), + Mock.Of<IConfiguration>(), + Mock.Of<IConfigurationManager>(), + Mock.Of<IPathManager>()); +} diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs new file mode 100644 index 0000000000..18518df1c2 --- /dev/null +++ b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs @@ -0,0 +1,100 @@ +using SkiaSharp; +using Xunit; + +namespace Jellyfin.Drawing.Skia.Tests; + +/// <summary> +/// Covers what <see cref="SkiaEncoder.ResizeImage"/> does either side of a resize: at matching +/// dimensions it must not touch the image at all, and sharpening belongs to downscales only. +/// </summary> +public class SkiaEncoderResizeTests +{ + private static SKBitmap CreateEdgeBitmap(int width, int height) + { + var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul)); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(new SKColor(40, 60, 80)); + using var paint = new SKPaint { Color = new SKColor(220, 210, 200) }; + canvas.DrawRect(SKRect.Create(0, 0, width / 2f, height), paint); + + return bitmap; + } + + private static SKImageInfo InfoFor(SKBitmap source, int width, int height) + => new SKImageInfo(width, height, source.ColorType, source.AlphaType, source.ColorSpace); + + /// <summary> + /// Draws without sharpening, which is what the resize is expected to reduce to when it is not + /// downscaling. + /// </summary> + private static SKBitmap DrawOnly(SKBitmap source, SKImageInfo targetInfo, SKSamplingOptions sampling) + { + var target = new SKBitmap(targetInfo); + using var canvas = new SKCanvas(target); + using var paint = new SKPaint(); + canvas.DrawBitmap( + source, + SKRect.Create(0, 0, source.Width, source.Height), + SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height), + sampling, + paint); + + return target; + } + + private static void AssertSamePixels(SKBitmap expected, SKBitmap actual) + { + Assert.Equal(expected.Width, actual.Width); + Assert.Equal(expected.Height, actual.Height); + + for (var y = 0; y < expected.Height; y++) + { + for (var x = 0; x < expected.Width; x++) + { + Assert.Equal(expected.GetPixel(x, y), actual.GetPixel(x, y)); + } + } + } + + [Fact] + public void ResizeImage_MatchingDimensions_ReturnsTheImageUntouched() + { + using var source = CreateEdgeBitmap(16, 16); + + using var result = SkiaEncoder.ResizeImage(source, InfoFor(source, 16, 16)); + using var resultBitmap = SKBitmap.FromImage(result); + + // Unsharpened, so the edge is still exactly where it was. + AssertSamePixels(source, resultBitmap); + } + + [Fact] + public void ResizeImage_Upscale_DoesNotSharpen() + { + using var source = CreateEdgeBitmap(8, 8); + var targetInfo = InfoFor(source, 24, 24); + + using var result = SkiaEncoder.ResizeImage(source, targetInfo); + using var resultBitmap = SKBitmap.FromImage(result); + using var expected = DrawOnly(source, targetInfo, SkiaEncoder.UpscaleSamplingOptions); + + AssertSamePixels(expected, resultBitmap); + } + + [Fact] + public void ResizeImage_Downscale_StillSharpens() + { + using var source = CreateEdgeBitmap(32, 32); + var targetInfo = InfoFor(source, 16, 16); + + using var result = SkiaEncoder.ResizeImage(source, targetInfo); + using var resultBitmap = SKBitmap.FromImage(result); + using var unsharpened = DrawOnly(source, targetInfo, SkiaEncoder.DefaultSamplingOptions); + using var sharpened = DrawOnly(source, targetInfo, SkiaEncoder.DefaultSamplingOptions); + SkiaEncoder.SharpenInPlace(sharpened); + + AssertSamePixels(sharpened, resultBitmap); + // Guards the test itself: the edge has to be something sharpening actually changes. + Assert.NotEqual(unsharpened.GetPixel(8, 8), sharpened.GetPixel(8, 8)); + } +} diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs new file mode 100644 index 0000000000..72e555cc72 --- /dev/null +++ b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs @@ -0,0 +1,73 @@ +using SkiaSharp; +using Xunit; + +namespace Jellyfin.Drawing.Skia.Tests; + +public class SkiaEncoderSharpenTests +{ + private static SKBitmap CreateBitmap(int width, int height, SKColor fill) + { + var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul)); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(fill); + return bitmap; + } + + [Fact] + public void SharpenInPlace_UniformImage_IsUnchanged() + { + // 1.4 * v - 4 * 0.1 * v = v for any uniform value. + using var bitmap = CreateBitmap(8, 8, new SKColor(100, 150, 200)); + + SkiaEncoder.SharpenInPlace(bitmap); + + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + Assert.Equal(new SKColor(100, 150, 200), bitmap.GetPixel(x, y)); + } + } + } + + [Fact] + public void SharpenInPlace_BrightPixelOnDarkBackground_SharpensEdge() + { + using var bitmap = CreateBitmap(5, 5, new SKColor(50, 50, 50)); + bitmap.SetPixel(2, 2, new SKColor(250, 250, 250, 255)); + + SkiaEncoder.SharpenInPlace(bitmap); + + // Center: 1.4 * 250 - 0.1 * 4 * 50 = 330 -> clamped to 255. + Assert.Equal(new SKColor(255, 255, 255), bitmap.GetPixel(2, 2)); + // Direct neighbor: 1.4 * 50 - 0.1 * (250 + 3 * 50) = 30. + Assert.Equal(new SKColor(30, 30, 30), bitmap.GetPixel(1, 2)); + // Far corner is only surrounded by background: unchanged. + Assert.Equal(new SKColor(50, 50, 50), bitmap.GetPixel(0, 0)); + } + + [Fact] + public void SharpenInPlace_EdgePixels_ClampOutOfBoundsTaps() + { + // A corner pixel reuses itself for the two out-of-bounds taps: + // 1.4 * v - 0.1 * (2 * v + right + down). + using var bitmap = CreateBitmap(3, 3, new SKColor(100, 100, 100)); + bitmap.SetPixel(0, 0, new SKColor(200, 200, 200, 255)); + + SkiaEncoder.SharpenInPlace(bitmap); + + // 1.4 * 200 - 0.1 * (200 + 200 + 100 + 100) = 220. + Assert.Equal(new SKColor(220, 220, 220), bitmap.GetPixel(0, 0)); + } + + [Fact] + public void SharpenInPlace_UnsupportedColorType_IsLeftUntouched() + { + using var bitmap = new SKBitmap(new SKImageInfo(4, 4, SKColorType.Gray8, SKAlphaType.Opaque)); + bitmap.Erase(new SKColor(80, 80, 80)); + + SkiaEncoder.SharpenInPlace(bitmap); + + Assert.Equal(80, bitmap.GetPixel(1, 1).Red); + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs new file mode 100644 index 0000000000..04c7b05c3d --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using Xunit; +using SchedulesDirectProvider = Jellyfin.LiveTv.Listings.SchedulesDirect; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class SchedulesDirectLineupTests +{ + private const string InvalidUserResponse = "{\"response\":\"INVALID_USER\",\"code\":4003,\"message\":\"Invalid user.\",\"serverID\":\"AWS-SD-web.1\"}"; + + private static readonly ListingsProviderInfo _info = new() { Username = "user", Password = "password" }; + + [Fact] + public async Task GetLineups_ValidCredentials_ReturnsLineups() + { + var tokenResponse = await CreateSuccessfulLogin(); + using var provider = CreateProvider(tokenResponse, await GetHeadendsResponse()); + + var lineups = await provider.GetLineups(_info, "USA", "90210"); + + Assert.NotEmpty(lineups); + Assert.Contains(lineups, i => string.Equals(i.Id, "USA-OTA-90210", StringComparison.Ordinal)); + Assert.Contains(lineups, i => string.Equals(i.Name, "Antenna", StringComparison.Ordinal)); + } + + [Fact] + public async Task GetLineups_LoginFails_Throws() + { + using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse()); + + // An empty lineup list is indistinguishable from "no lineups for this location", so a + // failed login has to surface as an error instead. + await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210")); + } + + [Fact] + public async Task Validate_LoginFails_Throws() + { + using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse()); + + await Assert.ThrowsAnyAsync<Exception>(() => provider.Validate(_info, true, false)); + } + + [Fact] + public async Task Validate_AfterAccountError_RecoversWithoutRestart() + { + var login = CreateFailedLogin(); + using var provider = CreateProvider(login, await GetHeadendsResponse()); + + await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210")); + + // The account error disables Schedules Direct; saving the provider again is the user + // correcting their credentials, and that has to recover without a server restart. + login.Status = HttpStatusCode.OK; + login.Body = await GetTokenResponse(); + + await provider.Validate(_info, true, false); + + Assert.NotEmpty(await provider.GetLineups(_info, "USA", "90210")); + } + + private static async Task<Response> CreateSuccessfulLogin() + => new() { Status = HttpStatusCode.OK, Body = await GetTokenResponse() }; + + private static Response CreateFailedLogin() + => new() { Status = HttpStatusCode.BadRequest, Body = InvalidUserResponse }; + + private static Task<string> GetTokenResponse() + => File.ReadAllTextAsync("Test Data/SchedulesDirect/token_live_response.json", TestContext.Current.CancellationToken); + + private static Task<string> GetHeadendsResponse() + => File.ReadAllTextAsync("Test Data/SchedulesDirect/headends_response.json", TestContext.Current.CancellationToken); + + private static SchedulesDirectProvider CreateProvider(Response login, string headendsResponse) + { + var messageHandler = new Mock<HttpMessageHandler>(); + messageHandler.Protected() + .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) + .Returns<HttpRequestMessage, CancellationToken>((m, _) => + { + var path = m.RequestUri!.AbsolutePath; + if (path.EndsWith("/token", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(login.Status) + { + Content = new StringContent(login.Body) + }); + } + + if (path.EndsWith("/headends", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(headendsResponse) + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + }); + + var httpClientFactory = new Mock<IHttpClientFactory>(); + httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())) + .Returns(() => new HttpClient(messageHandler.Object)); + + var appPaths = new Mock<IApplicationPaths>(); + appPaths.SetupGet(x => x.CachePath).Returns(Path.GetTempPath()); + + return new SchedulesDirectProvider( + NullLogger<SchedulesDirectProvider>.Instance, + httpClientFactory.Object, + appPaths.Object); + } + + private sealed class Response + { + public HttpStatusCode Status { get; set; } + + public string Body { get; set; } = string.Empty; + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs new file mode 100644 index 0000000000..1d96c5a958 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public sealed class XmlTvListingsProviderCacheTests : IDisposable +{ + private const string ChannelId = "3297"; + + private readonly string _cachePath = Path.Combine(Path.GetTempPath(), "jellyfin-xmltv-tests-" + Guid.NewGuid().ToString("N")); + + private readonly ListingsProviderInfo _info = new() + { + Id = "cachetests", + Path = "https://example.com/notitle.xml" + }; + + private bool _downloadsFail; + private Exception? _downloadException; + + public void Dispose() + { + if (Directory.Exists(_cachePath)) + { + Directory.Delete(_cachePath, true); + } + } + + [Fact] + public async Task GetProgramsAsync_DownloadFailsAfterASuccess_KeepsUsingTheCachedListings() + { + var provider = CreateProvider(); + + Assert.NotEmpty(await GetPrograms(provider)); + + // Age the cached copy out, so the next call goes back to the (now broken) source. + var cacheFile = Path.Combine(_cachePath, "xmltv", _info.Id + ".xml"); + File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddDays(-1)); + _downloadsFail = true; + + // Losing the listings entirely because a single download failed empties the whole guide. + Assert.NotEmpty(await GetPrograms(provider)); + Assert.True(File.Exists(cacheFile)); + } + + [Fact] + public async Task GetProgramsAsync_DownloadTimesOut_DoesNotSurfaceAsCancellation() + { + var provider = CreateProvider(); + + // This is how HttpClient reports its own timeout. Left as an OperationCanceledException it + // aborts the guide refresh for every channel and provider instead of only this one. + _downloadsFail = true; + _downloadException = new TaskCanceledException("timeout", new TimeoutException()); + + await Assert.ThrowsAsync<TimeoutException>(() => GetPrograms(provider)); + } + + [Fact] + public async Task GetProgramsAsync_ProviderSavedAfterAFailure_DownloadsAgain() + { + var provider = CreateProvider(); + + _downloadsFail = true; + await Assert.ThrowsAnyAsync<Exception>(() => GetPrograms(provider)); + + // Without clearing the backoff the guide stays empty for an hour, even though saving the + // provider deletes the cached file and is the user asking for another attempt. + _downloadsFail = false; + await provider.Validate(_info, true, true); + + Assert.NotEmpty(await GetPrograms(provider)); + } + + private async Task<ProgramInfo[]> GetPrograms(XmlTvListingsProvider provider) + { + var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc); + var programs = await provider.GetProgramsAsync(_info, ChannelId, startDate, startDate.AddDays(1), CancellationToken.None); + + return programs.ToArray(); + } + + private XmlTvListingsProvider CreateProvider() + { + var messageHandler = new Mock<HttpMessageHandler>(); + messageHandler.Protected() + .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) + .Returns<HttpRequestMessage, CancellationToken>((m, _) => + { + if (_downloadException is not null) + { + return Task.FromException<HttpResponseMessage>(_downloadException); + } + + if (_downloadsFail) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1]))) + }); + }); + + var httpClientFactory = new Mock<IHttpClientFactory>(); + httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())) + .Returns(() => new HttpClient(messageHandler.Object)); + + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.SetupGet(x => x.CachePath).Returns(_cachePath); + + var config = new Mock<IServerConfigurationManager>(); + config.SetupGet(x => x.ApplicationPaths).Returns(appPaths.Object); + config.SetupGet(x => x.Configuration).Returns(new ServerConfiguration()); + + return new XmlTvListingsProvider( + config.Object, + httpClientFactory.Object, + NullLogger<XmlTvListingsProvider>.Instance); + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs index f698edc637..a8ebc8c9b9 100644 --- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs @@ -90,6 +90,49 @@ public class XmlTvListingsProviderTests AssertXmlTvEtag(program.Etag); } + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")] + [InlineData("https://example.com/no-optional-elements.xml")] + public async Task GetProgramsAsync_NoOptionalElements_Success(string path) + { + var info = new ListingsProviderInfo() + { + Id = "no-optional-elements-programs", + Path = path + }; + + var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + var program = Assert.Single(programs.ToList()); + Assert.Equal("Programme Without Icon Or Rating", program.Name); + Assert.False(program.HasImage); + Assert.Null(program.ImageUrl); + Assert.Null(program.ThumbImageUrl); + Assert.Null(program.BackdropImageUrl); + Assert.Null(program.OfficialRating); + Assert.Null(program.CommunityRating); + AssertXmlTvEtag(program.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")] + [InlineData("https://example.com/no-optional-elements.xml")] + public async Task GetChannels_NoOptionalElements_Success(string path) + { + var info = new ListingsProviderInfo() + { + Id = "no-optional-elements-channels", + Path = path + }; + + var channels = await _xmlTvListingsProvider.GetChannels(info, CancellationToken.None); + var channel = Assert.Single(channels); + Assert.Equal("3297", channel.Id); + Assert.Equal("Channel Without Icon", channel.Name); + Assert.Equal("3297", channel.Number); + Assert.Null(channel.ImageUrl); + } + [Fact] public async Task GetProgramsAsync_Etag_SameContentIsStable() { diff --git a/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs new file mode 100644 index 0000000000..4487a5ff2b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.TunerHosts; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.LiveTv.Tests +{ + public class M3UTunerHostTests + { + [Theory] + // A manifest is not a byte stream, so it must never be offered for direct play. + [InlineData("http://example.com/live/1234.m3u8", false)] + [InlineData("http://example.com/live/1234.m3u8?token=abc", false)] + [InlineData("http://example.com/live/1234.mpd", false)] + // Byte streams are unaffected. + [InlineData("http://example.com/live/1234.ts", true)] + [InlineData("http://example.com/live/1234", true)] + public async Task GetChannelStreamMediaSources_ManifestPath_DisablesDirectPlay(string path, bool expectDirectPlay) + { + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.Http); + + var host = new TestableM3UTunerHost( + Mock.Of<IServerConfigurationManager>(), + mediaSourceManager.Object, + Mock.Of<ILogger<M3UTunerHost>>(), + Mock.Of<IFileSystem>(), + Mock.Of<IHttpClientFactory>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<INetworkManager>(), + Mock.Of<IStreamHelper>()); + + var sources = await host.GetMediaSources( + new TunerHostInfo { TunerCount = 0, EnableStreamLooping = false }, + new ChannelInfo { Path = path }); + + Assert.Equal(expectDirectPlay, sources[0].SupportsDirectPlay); + } + + private sealed class TestableM3UTunerHost : M3UTunerHost + { + public TestableM3UTunerHost( + IServerConfigurationManager config, + IMediaSourceManager mediaSourceManager, + ILogger<M3UTunerHost> logger, + IFileSystem fileSystem, + IHttpClientFactory httpClientFactory, + IServerApplicationHost appHost, + INetworkManager networkManager, + IStreamHelper streamHelper) + : base(config, mediaSourceManager, logger, fileSystem, httpClientFactory, appHost, networkManager, streamHelper) + { + } + + public Task<List<MediaSourceInfo>> GetMediaSources(TunerHostInfo tuner, ChannelInfo channel) + => GetChannelStreamMediaSources(tuner, channel, CancellationToken.None); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml new file mode 100644 index 0000000000..e82d00c259 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml @@ -0,0 +1,10 @@ +<tv date="20221104"> + <channel id="3297"> + <display-name>Channel Without Icon</display-name> + </channel> + <programme channel="3297" start="20221104130000 +0000" stop="20221105235959 +0000"> + <title lang="en">Programme Without Icon Or Rating</title> + <desc lang="en">A programme that only uses the required XMLTV elements.</desc> + <category lang="en">sports</category> + </programme> +</tv> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs new file mode 100644 index 0000000000..141164815c --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.MediaEncoding.Encoder; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.MediaEncoding.Tests.Encoder; + +public class ProcessWrapperTests +{ + [Fact] + public async Task ExitedProcess_StaysUsableForTheCallerThatStartedIt() + { + using var process = CreateProcess(); + using var exitHandled = new ManualResetEventSlim(false); + + using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder())) + { + // Subscribed after the wrapper, so by the time this is set the wrapper's own handler has + // already run: whatever it does to the process has happened. + process.Exited += (_, _) => exitHandled.Set(); + + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + Assert.True(exitHandled.Wait(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken), "The process never raised Exited."); + + // The caller still owns the process here. Disposing it from the exit handler handed + // whoever exited quickest an ObjectDisposedException out of these three lines. + var output = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + Assert.Equal("jellyfin", output.Trim()); + + Assert.True(wrapper.HasExited); + Assert.Equal(3, wrapper.ExitCode); + } + } + + [Fact] + public async Task ExitState_IsReadableBeforeTheExitEventArrives() + { + using var process = CreateProcess(); + + using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder())) + { + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + // The exit event is raised on the thread pool and can lag behind the wait that just + // returned, so neither of these may depend on it having arrived. + Assert.True(wrapper.HasExited); + Assert.Equal(3, wrapper.ExitCode); + } + } + + [Fact] + public async Task ExitCode_SurvivesDisposal() + { + using var process = CreateProcess(); + var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()); + + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + var exitCode = wrapper.ExitCode; + wrapper.Dispose(); + + Assert.Equal(exitCode, wrapper.ExitCode); + Assert.True(wrapper.HasExited); + } + + private static MediaEncoder CreateEncoder() + => new( + Mock.Of<ILogger<MediaEncoder>>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<IFileSystem>(), + Mock.Of<IBlurayExaminer>(), + Mock.Of<ILocalizationManager>(), + new ConfigurationBuilder().Build(), + Mock.Of<IServerConfigurationManager>()); + + // Writes to stdout and exits immediately with a non-zero code, standing in for the ffprobe that + // rejects a file outright - the process that used to win the race against its own caller. + private static Process CreateProcess() + { + var startInfo = OperatingSystem.IsWindows() + ? new ProcessStartInfo("cmd.exe", "/c echo jellyfin & exit 3") + : new ProcessStartInfo("/bin/sh", "-c \"printf 'jellyfin\\n'; exit 3\""); + + startInfo.CreateNoWindow = true; + startInfo.UseShellExecute = false; + startInfo.RedirectStandardOutput = true; + + return new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs new file mode 100644 index 0000000000..dfd1eb2e85 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs @@ -0,0 +1,104 @@ +using System; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Model.Tests.Dlna; + +public class StreamBuilderManifestContainerTests +{ + [Theory] + // A manifest describes a stream instead of carrying one, so it can never be direct played, + // even when the client claims to support the container. + [InlineData("hls")] + [InlineData("hls,applehttp")] + [InlineData("applehttp")] + [InlineData("dash")] + public void GetOptimalVideoStream_ManifestContainer_DoesNotDirectPlay(string container) + { + var streamInfo = BuildFor(container); + + Assert.NotNull(streamInfo); + Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod); + } + + [Fact] + public void GetOptimalVideoStream_ByteStreamContainer_StillDirectPlays() + { + var streamInfo = BuildFor("mp4"); + + Assert.NotNull(streamInfo); + Assert.Equal(PlayMethod.DirectPlay, streamInfo.PlayMethod); + } + + private static StreamInfo? BuildFor(string container) + { + var mediaSource = new MediaSourceInfo + { + Id = "test-source", + Path = "http://example.com/live/channel", + Protocol = MediaProtocol.Http, + Container = container, + SupportsDirectPlay = true, + SupportsDirectStream = true, + SupportsTranscoding = true, + IsInfiniteStream = true, + IsRemote = true, + MediaStreams = + [ + new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264" }, + new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac" } + ] + }; + + var profile = new DeviceProfile + { + Name = "Manifest aware client", + DirectPlayProfiles = + [ + new DirectPlayProfile + { + Type = DlnaProfileType.Video, + Container = "mp4,hls,applehttp,dash", + VideoCodec = "h264", + AudioCodec = "aac" + } + ], + TranscodingProfiles = + [ + new TranscodingProfile + { + Type = DlnaProfileType.Video, + Context = EncodingContext.Streaming, + Protocol = MediaStreamProtocol.hls, + Container = "ts", + VideoCodec = "h264", + AudioCodec = "aac" + } + ] + }; + + var options = new MediaOptions + { + ItemId = new Guid("11D229B7-2D48-4B95-9F9B-49F6AB75E613"), + MediaSourceId = mediaSource.Id, + MediaSources = [mediaSource], + DeviceId = "test-deviceId", + Profile = profile, + AllowAudioStreamCopy = true, + AllowVideoStreamCopy = true, + EnableDirectStream = false // This is disabled in server + }; + + var transcodeSupport = new Mock<ITranscoderSupport>(); + + return new StreamBuilder(transcodeSupport.Object, new NullLogger<StreamBuilderManifestContainerTests>()) + .GetOptimalVideoStream(options); + } +} diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs new file mode 100644 index 0000000000..f264e5a019 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs @@ -0,0 +1,129 @@ +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.Model.Tests.Entities; + +public class MediaStreamVideoRangeTests +{ + [Theory] + [InlineData(7, 6, "smpte2084", false, VideoRangeType.DOVIWithEL)] + [InlineData(7, 6, "smpte2084", true, VideoRangeType.DOVIWithELHDR10Plus)] + [InlineData(8, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)] + [InlineData(8, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)] + [InlineData(8, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)] + [InlineData(10, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)] + [InlineData(10, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)] + [InlineData(10, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)] + [InlineData(8, 1, "SMPTE2084", false, VideoRangeType.DOVIWithHDR10)] + [InlineData(8, 4, "ARIB-STD-B67", false, VideoRangeType.DOVIWithHLG)] + public void GetVideoColorRange_ValidDovi_PreservesRangeType( + int profile, int compatibilityId, string transfer, bool hdr10Plus, VideoRangeType expected) + { + var stream = CreateDovi(profile, compatibilityId, "BT2020NC", transfer, "BT2020", hdr10Plus); + + Assert.Equal((VideoRange.HDR, expected), stream.GetVideoColorRange()); + } + + [Theory] + [InlineData("bt709", "bt709", "bt709", VideoRange.SDR)] + [InlineData("bt2020nc", "bt709", "bt2020", VideoRange.SDR)] + [InlineData("bt2020nc", null, "bt2020", VideoRange.SDR)] + [InlineData("bt2020nc", "", "bt2020", VideoRange.SDR)] + [InlineData("bt2020nc", "unknown", "bt2020", VideoRange.SDR)] + [InlineData("bt2020nc", "bt2020-10", "bt2020", VideoRange.SDR)] + [InlineData(null, null, null, VideoRange.SDR)] + [InlineData("bt709", "smpte2084", "bt2020", VideoRange.HDR)] + [InlineData("bt2020nc", "smpte2084", "bt709", VideoRange.HDR)] + [InlineData(null, "smpte2084", "bt2020", VideoRange.HDR)] + [InlineData("bt2020nc", "smpte2084", null, VideoRange.HDR)] + [InlineData("bt709", "arib-std-b67", "bt2020", VideoRange.HDR)] + [InlineData("bt2020nc", "arib-std-b67", "bt709", VideoRange.HDR)] + [InlineData(null, "arib-std-b67", "bt2020", VideoRange.HDR)] + [InlineData("bt2020nc", "arib-std-b67", null, VideoRange.HDR)] + public void GetVideoColorRange_InvalidDoviColors_UsesBaseLayerRange( + string? space, string? transfer, string? primaries, VideoRange expected) + { + // Cover every HDR-compatible DV profile, including the HDR10+ variants. + foreach (var (profile, compatibilityId) in new[] { (7, 6), (8, 1), (8, 4), (10, 1), (10, 4) }) + { + foreach (var hdr10Plus in new[] { false, true }) + { + var stream = CreateDovi(profile, compatibilityId, space, transfer, primaries, hdr10Plus); + + Assert.Equal(expected, stream.VideoRange); + Assert.Equal(VideoRangeType.DOVIInvalid, stream.VideoRangeType); + } + } + } + + [Theory] + [InlineData(7, 6, "arib-std-b67")] + [InlineData(8, 1, "arib-std-b67")] + [InlineData(8, 4, "smpte2084")] + [InlineData(10, 1, "arib-std-b67")] + [InlineData(10, 4, "smpte2084")] + public void GetVideoColorRange_WrongHdrTransfer_InvalidButStillHdr(int profile, int compatibilityId, string transfer) + { + var stream = CreateDovi(profile, compatibilityId, "bt2020nc", transfer, "bt2020", true); + + Assert.Equal((VideoRange.HDR, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange()); + } + + [Theory] + [InlineData(5, 0, null, VideoRange.HDR, VideoRangeType.DOVI)] + [InlineData(10, 0, null, VideoRange.HDR, VideoRangeType.DOVI)] + [InlineData(8, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)] + [InlineData(10, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)] + public void GetVideoColorRange_OtherDoviProfiles_PreservesClassification( + int profile, int compatibilityId, string? transfer, VideoRange range, VideoRangeType rangeType) + { + var stream = CreateDovi(profile, compatibilityId, "bt709", transfer, "bt709", false); + + Assert.Equal((range, rangeType), stream.GetVideoColorRange()); + } + + [Theory] + [InlineData(8, null, VideoRange.SDR)] + [InlineData(8, "bt709", VideoRange.SDR)] + [InlineData(8, "smpte2084", VideoRange.HDR)] + [InlineData(10, null, VideoRange.SDR)] + [InlineData(10, "arib-std-b67", VideoRange.HDR)] + public void GetVideoColorRange_InvalidCompatibilityId_UsesBaseLayerRange(int profile, string? transfer, VideoRange expected) + { + var stream = CreateDovi(profile, 6, "bt2020nc", transfer, "bt2020", false); + + Assert.Equal((expected, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange()); + } + + [Theory] + [InlineData("bt709", false, VideoRange.SDR, VideoRangeType.SDR)] + [InlineData(null, false, VideoRange.SDR, VideoRangeType.SDR)] + [InlineData("smpte2084", false, VideoRange.HDR, VideoRangeType.HDR10)] + [InlineData("smpte2084", true, VideoRange.HDR, VideoRangeType.HDR10Plus)] + [InlineData("arib-std-b67", false, VideoRange.HDR, VideoRangeType.HLG)] + public void GetVideoColorRange_WithoutDovi_PreservesClassification( + string? transfer, bool hdr10Plus, VideoRange range, VideoRangeType rangeType) + { + var stream = new MediaStream { Type = MediaStreamType.Video, ColorTransfer = transfer, Hdr10PlusPresentFlag = hdr10Plus }; + + Assert.Equal((range, rangeType), stream.GetVideoColorRange()); + stream.Type = MediaStreamType.Audio; + Assert.Equal((VideoRange.Unknown, VideoRangeType.Unknown), stream.GetVideoColorRange()); + } + + private static MediaStream CreateDovi(int profile, int compatibilityId, string? space, string? transfer, string? primaries, bool hdr10Plus) + => new() + { + Type = MediaStreamType.Video, + DvProfile = profile, + DvBlSignalCompatibilityId = compatibilityId, + RpuPresentFlag = 1, + BlPresentFlag = 1, + ElPresentFlag = profile == 7 ? 1 : 0, + ColorSpace = space, + ColorTransfer = transfer, + ColorPrimaries = primaries, + Hdr10PlusPresentFlag = hdr10Plus + }; +} diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs index 7e708c681d..4236749423 100644 --- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs @@ -74,6 +74,9 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Season 5/S05E23 11-59 [HDTV-1080p][x265 AC3].mkv", null)] [InlineData("Season 5/S05E23 11-59 [HDTV-1080p][HEVC AC3].mkv", null)] [InlineData("Season 1/S01E01 1-23-45 [Bluray-1080p][AV1 Opus].mkv", null)] + // Episode markers in the episode title must not be read as an episode range + [InlineData("Season 03/Star Trek Enterprise (2001) - S03E21 - E2 (1080p BluRay x265).mkv", null)] + [InlineData("Season 02/Series Name (2001) - S02E10 - E5 [WEBRip-1080p].mkv", null)] public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber) { var result = _episodePathParser.Parse(filename, false); diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs index b81b7934cd..023c6cb2fa 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs @@ -20,6 +20,11 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("/some/path/The Show s02e10 720p hdtv", "The Show")] [InlineData("/some/path/The Show s02e10 the episode 720p hdtv", "The Show")] [InlineData("/some/path/1923 (2022)", "1923")] + // A dotted acronym keeps its dots when it follows words, whether they are space or dot separated + [InlineData("/some/path/Marvel's Agents of S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")] + [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")] public void SeriesResolverResolveTest(string path, string name) { var res = SeriesResolver.Resolve(_namingOptions, path); diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index b29c64f50d..62c1cf25d7 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -1130,5 +1130,45 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal(2, result[0].Files.Count); Assert.Single(result[0].AlternateVersions); } + + [Fact] + public void TestMultiVersionEpisodeAbsoluteNumberingWithNumberInSeriesTitle() + { + // Every one of these parses as episode 2, because the expressions read the "2" of the + // series title as an absolute episode number. They are distinct episodes all the same. + var files = new[] + { + "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 01 - The Memory of a Summer (b6f40849).mkv", + "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 02 - Heart Pain Killer (d8c0896c).mkv", + "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 03 - Translucent Chord (4ecce3dd).mkv", + "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 04 - The Mysterious Lady (837a1909).mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(4, result.Count); + Assert.All(result, r => Assert.Empty(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeAbsoluteNumberingDontCollapse() + { + // Plain absolute numbering: no season number is available, so the files stay separate. + var files = new[] + { + "/anime/Bleach/Bleach - 001 - The Day I Became a Shinigami.mkv", + "/anime/Bleach/Bleach - 002 - The Shinigami's Work.mkv", + "/anime/Bleach/Bleach - 003 - The Older Brother's Wish.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(3, result.Count); + Assert.All(result, r => Assert.Empty(r.AlternateVersions)); + } } } diff --git a/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs b/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs new file mode 100644 index 0000000000..f7f29d9768 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Books.ComicBookInfo; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Books; + +public sealed class ComicBookInfoProviderTests : IDisposable +{ + private const string ValidComment = """ + {"appID":"test","ComicBookInfo/1.0":{"series":"Jungle Juice","title":"Episode 36","issue":175}} + """; + + private readonly string _directory; + + public ComicBookInfoProviderTests() + { + _directory = Path.Combine(Path.GetTempPath(), "jf-cbz-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + public void Dispose() + { + Directory.Delete(_directory, true); + } + + [Theory] + [InlineData(null)] // archive written without ever touching Comment + [InlineData("")] + [InlineData(" ")] + public async Task ReadMetadata_EmptyArchiveComment_SkipsWithoutDeserializing(string? comment) + { + var logger = new Mock<ILogger<ComicBookInfoProvider>>(); + var path = CreateArchive(comment); + var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object); + + var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None); + + Assert.False(result.HasMetadata); + VerifyLogged(logger, LogLevel.Debug, "missing ComicBookInfo in archive comment"); + VerifyNothingLoggedAbove(logger, LogLevel.Debug); + } + + [Fact] + public async Task ReadMetadata_ArchiveCommentIsNotComicBookInfo_SkipsWithoutError() + { + var logger = new Mock<ILogger<ComicBookInfoProvider>>(); + var path = CreateArchive("Created by some packer"); + var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object); + + var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None); + + Assert.False(result.HasMetadata); + VerifyLogged(logger, LogLevel.Debug, "archive comment is not valid ComicBookInfo metadata"); + VerifyNothingLoggedAbove(logger, LogLevel.Debug); + } + + [Fact] + public async Task ReadMetadata_ValidComicBookInfoComment_ReturnsMetadata() + { + var logger = new Mock<ILogger<ComicBookInfoProvider>>(); + var path = CreateArchive(ValidComment); + var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object); + + var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None); + + Assert.True(result.HasMetadata); + Assert.NotNull(result.Item); + Assert.Equal("Episode 36", result.Item.Name); + Assert.Equal("Jungle Juice", result.Item.SeriesName); + Assert.Equal(175, result.Item.IndexNumber); + VerifyNothingLoggedAbove(logger, LogLevel.Debug); + } + + private static void VerifyLogged(Mock<ILogger<ComicBookInfoProvider>> logger, LogLevel level, string message) + { + logger.Verify( + x => x.Log( + level, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains(message, StringComparison.Ordinal)), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Once); + } + + private static void VerifyNothingLoggedAbove(Mock<ILogger<ComicBookInfoProvider>> logger, LogLevel level) + { + // a comment that holds no ComicBookInfo is normal, so it must not reach the log of a default install + logger.Verify( + x => x.Log( + It.Is<LogLevel>(actual => actual > level), + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + private static IFileSystem CreateFileSystem(string path) + { + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(x => x.GetFileSystemInfo(path)) + .Returns(new FileSystemMetadata + { + Exists = true, + FullName = path, + Name = Path.GetFileName(path), + Extension = ".cbz", + IsDirectory = false + }); + + return fileSystem.Object; + } + + private string CreateArchive(string? comment) + { + var path = Path.Combine(_directory, Guid.NewGuid().ToString("N") + ".cbz"); + + using (var stream = File.Create(path)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create)) + { + if (comment is not null) + { + archive.Comment = comment; + } + + var entry = archive.CreateEntry("ComicInfo.xml"); + using var writer = new StreamWriter(entry.Open()); + writer.Write("<ComicInfo />"); + } + + return path; + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index 1ec859223e..459973acba 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -557,6 +557,47 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal(expectedToUpdate, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); } + [Fact] + public async Task RefreshImages_ProviderDynamicThrows_CountsAFailure() + { + var item = GetItemWithImages(ImageType.Primary, 0, false); + var libraryOptions = GetLibraryOptions(item, ImageType.Primary, 1); + + var dynamicProvider = new Mock<IDynamicImageProvider>(MockBehavior.Strict); + dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider"); + dynamicProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { ImageType.Primary }); + dynamicProvider.Setup(rp => rp.GetImage(item, ImageType.Primary, It.IsAny<CancellationToken>())) + .ThrowsAsync(new InvalidOperationException("provider is broken")); + + var itemImageProvider = GetItemImageProvider(null, new Mock<IFileSystem>()); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { dynamicProvider.Object }, new ImageRefreshOptions(Mock.Of<IDirectoryService>()), CancellationToken.None); + + // Without this the caller stamps DateLastRefreshed and never asks this provider again. + Assert.Equal(1, result.Failures); + } + + [Fact] + public async Task RefreshImages_ProviderRemoteThrows_CountsAFailure() + { + var item = GetItemWithImages(ImageType.Primary, 0, false); + var libraryOptions = GetLibraryOptions(item, ImageType.Primary, 1); + + var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { ImageType.Primary }); + + var providerManager = new Mock<IProviderManager>(MockBehavior.Strict); + providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>())) + .ThrowsAsync(new HttpRequestException("unreachable")); + + var itemImageProvider = GetItemImageProvider(providerManager.Object, new Mock<IFileSystem>()); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, new ImageRefreshOptions(Mock.Of<IDirectoryService>()), CancellationToken.None); + + Assert.Equal(1, result.Failures); + } + private static ItemImageProvider GetItemImageProvider(IProviderManager? providerManager, Mock<IFileSystem>? mockFileSystem) { // strict to ensure this isn't accidentally used where a prepared mock is intended diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs index 3b4d6fc9bb..acf8de4366 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -287,6 +287,161 @@ namespace Jellyfin.Providers.Tests.Manager } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RefreshMetadata_CustomProviderThrew_LeavesRefreshDateAlone(bool providerThrows) + { + var item = new TestItem + { + Id = Guid.NewGuid(), + Name = "Test Item", + PreferredMetadataLanguage = "en", + PreferredMetadataCountryCode = "US", + DateLastRefreshed = DateTime.UtcNow.AddDays(-60), + DateLastSaved = DateTime.UtcNow.AddDays(-60) + }; + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + var stampBefore = item.DateLastRefreshed; + + // Stands in for the probe provider, whose only other change monitor is the file's + // modification time: if a throw is stamped as a completed refresh the item is never revisited. + var provider = new Mock<ICustomMetadataProvider<TestItem>>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Throwing Provider"); + provider.Setup(p => p.FetchAsync(It.IsAny<TestItem>(), It.IsAny<MetadataRefreshOptions>(), It.IsAny<CancellationToken>())) + .Returns(providerThrows + ? Task.FromException<ItemUpdateType>(new InvalidOperationException("probe failed")) + : Task.FromResult(ItemUpdateType.None)); + + var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose); + libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + + var providerManager = new Mock<IProviderManager>(MockBehavior.Loose); + providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>())) + .Returns(Array.Empty<IImageProvider>()); + providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(new[] { (IMetadataProvider<TestItem>)provider.Object }); + providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(Array.Empty<IMetadataSaver>()); + + var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose); + itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true); + + var service = new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh + }, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal(providerThrows, item.DateLastRefreshed == stampBefore); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RefreshMetadata_ImageProviderThrew_LeavesRefreshDateAlone(bool providerThrows) + { + var item = NewStampedTestItem(); + var stampBefore = item.DateLastRefreshed; + + var imageProvider = new Mock<IDynamicImageProvider>(MockBehavior.Loose); + imageProvider.Setup(p => p.Name).Returns("Throwing Image Provider"); + imageProvider.Setup(p => p.GetSupportedImages(It.IsAny<BaseItem>())).Returns(new[] { ImageType.Primary }); + imageProvider.Setup(p => p.GetImage(It.IsAny<BaseItem>(), ImageType.Primary, It.IsAny<CancellationToken>())) + .Returns(providerThrows + ? Task.FromException<DynamicImageResponse>(new InvalidOperationException("image fetch failed")) + : Task.FromResult(new DynamicImageResponse { HasImage = false })); + + var providerManager = NewProviderManager(imageProviders: [imageProvider.Object]); + + var service = NewService(providerManager); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh + }, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal(providerThrows, item.DateLastRefreshed == stampBefore); + } + + [Fact] + public async Task RefreshMetadata_LocalImageValidationThrew_LeavesRefreshDateAlone() + { + var item = NewStampedTestItem(); + var stampBefore = item.DateLastRefreshed; + + // A throw here skips the remote image stage altogether, so no image work happened at all. + var localImageProvider = new Mock<ILocalImageProvider>(MockBehavior.Loose); + localImageProvider.Setup(p => p.Name).Returns("Throwing Local Image Provider"); + localImageProvider.Setup(p => p.GetImages(It.IsAny<BaseItem>(), It.IsAny<IDirectoryService>())) + .Throws(new UnauthorizedAccessException("metadata folder is not readable")); + + var providerManager = NewProviderManager(imageProviders: [localImageProvider.Object]); + + var service = NewService(providerManager); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh + }, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal(stampBefore, item.DateLastRefreshed); + } + + private static TestItem NewStampedTestItem() + { + var item = new TestItem + { + Id = Guid.NewGuid(), + Name = "Test Item", + PreferredMetadataLanguage = "en", + PreferredMetadataCountryCode = "US", + DateLastRefreshed = DateTime.UtcNow.AddDays(-60), + DateLastSaved = DateTime.UtcNow.AddDays(-60) + }; + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + return item; + } + + private static Mock<IProviderManager> NewProviderManager( + IMetadataProvider<TestItem>[]? metadataProviders = null, + IImageProvider[]? imageProviders = null) + { + var providerManager = new Mock<IProviderManager>(MockBehavior.Loose); + providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>())) + .Returns(imageProviders ?? Array.Empty<IImageProvider>()); + providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(metadataProviders ?? Array.Empty<IMetadataProvider<TestItem>>()); + providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(Array.Empty<IMetadataSaver>()); + return providerManager; + } + + private static TestItemMetadataService NewService(Mock<IProviderManager> providerManager) + { + var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose); + libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + + var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose); + itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true); + + return new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object); + } + /// <summary> /// Stands in for a real item so the refresh stays off the shared BaseItem statics, which other /// test classes in this assembly overwrite while xUnit runs them in parallel. diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 5749944fcd..d3b82cefcd 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -377,6 +378,116 @@ namespace Jellyfin.Providers.Tests.Manager GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true); } + [Fact] + public async Task QueueRefresh_ManyItemsQueuedFromManyThreads_ProcessesEveryOne() + { + const int ItemCount = 2000; + + var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray(); + var processed = new ConcurrentBag<Guid>(); + var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>())) + .Returns((Guid id) => + { + // Returning null drains the entry without the whole refresh machinery. + processed.Add(id); + if (processed.Count == ItemCount) + { + allProcessed.TrySetResult(); + } + + return null; + }); + + using var providerManager = GetProviderManager(libraryManager: libraryManager.Object); + + await Parallel.ForEachAsync( + queued, + TestContext.Current.CancellationToken, + (id, _) => + { + providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal); + return ValueTask.CompletedTask; + }); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + + try + { + await allProcessed.Task.WaitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + // Fall through so the assertions report what was lost. + } + + Assert.Empty(providerManager.GetRefreshQueue()); + Assert.Equal(queued.Order().ToArray(), processed.Order().ToArray()); + } + + [Fact] + public async Task QueueRefresh_RefreshCancelsForItsOwnReasons_KeepsDrainingTheQueue() + { + // A provider timeout arrives as an OperationCanceledException, indistinguishable from + // a shutdown; treating it as one would strand the rest of the queue. + const int ItemCount = 200; + + var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray(); + var processed = new ConcurrentBag<Guid>(); + var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var allQueued = new ManualResetEventSlim(false); + var cancelledOnce = false; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>())) + .Returns((Guid id) => + { + if (!cancelledOnce) + { + cancelledOnce = true; + + // Hold the first entry until the whole batch is queued. + allQueued.Wait(TimeSpan.FromSeconds(30)); + throw new OperationCanceledException("provider timed out"); + } + + processed.Add(id); + if (processed.Count == ItemCount - 1) + { + allProcessed.TrySetResult(); + } + + return null; + }); + + using var providerManager = GetProviderManager(libraryManager: libraryManager.Object); + + foreach (var id in queued) + { + providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal); + } + + allQueued.Set(); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + + try + { + await allProcessed.Task.WaitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + // Fall through so the assertions report what was stranded. + } + + Assert.Empty(providerManager.GetRefreshQueue()); + Assert.Equal(ItemCount - 1, processed.Count); + } + private static void GetMetadataProviders_CanRefreshMetadata_Tester( string providerType, bool expected, @@ -554,15 +665,22 @@ namespace Jellyfin.Providers.Tests.Manager private static ProviderManager GetProviderManager( ServerConfiguration? serverConfiguration = null, LibraryOptions? libraryOptions = null, - IBaseItemManager? baseItemManager = null) + IBaseItemManager? baseItemManager = null, + ILibraryManager? libraryManager = null) { var serverConfigurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Strict); serverConfigurationManager.Setup(i => i.Configuration) .Returns(serverConfiguration ?? new ServerConfiguration()); - var libraryManager = new Mock<ILibraryManager>(MockBehavior.Strict); - libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>())) - .Returns(libraryOptions ?? new LibraryOptions()); + if (libraryManager is null) + { + var libraryManagerMock = new Mock<ILibraryManager>(MockBehavior.Strict); + libraryManagerMock.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>())) + .Returns(libraryOptions ?? new LibraryOptions()); + libraryManagerMock.Setup(i => i.GetCollectionFolders(It.IsAny<BaseItem>())) + .Returns(new List<Folder>()); + libraryManager = libraryManagerMock.Object; + } var providerManager = new ProviderManager( Mock.Of<IHttpClientFactory>(), @@ -572,7 +690,7 @@ namespace Jellyfin.Providers.Tests.Manager _logger, Mock.Of<IFileSystem>(), Mock.Of<IServerApplicationPaths>(), - libraryManager.Object, + libraryManager, baseItemManager!, Mock.Of<ILyricManager>(), Mock.Of<IMemoryCache>(), diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 876f18741f..ce451861ef 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -179,6 +179,146 @@ public class MediaInfoResolverTests Assert.Empty(streams); } + [Fact] + public void GetExternalFiles_VobSubIdxAndSubPair_OnlyReturnsIdxFile() + { + // VobSub (.sub) payloads only carry per-track language metadata when read + // alongside their paired .idx index file. When both are present, only the + // .idx file should be returned so it (not the raw .sub) gets probed. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.sub" }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + + var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetExternalFiles_VobSubIdxWithoutMatchingSub_DoesNotReturnIdxFile() + { + // An .idx file with no paired .sub cannot be probed for anything, so it must be + // left out entirely rather than surfaced as a doomed-to-fail probe candidate. + // Surfacing it anyway would also make it "exist" from Jellyfin's perspective + // even after the real .sub is deleted, preventing stale subtitle stream data + // from ever being cleared on a rescan. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = GetDirectoryServiceForExternalFile("My.Video.idx"); + var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList(); + + Assert.Empty(streams); + } + + [Fact] + public void GetExternalFiles_StandaloneSubWithoutIdx_StillReturnsSubFile() + { + // Guards against the .idx/.sub pairing suppression firing when there is no + // .idx sidecar at all - a lone .sub file must still be returned. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = GetDirectoryServiceForExternalFile("My.Video.sub"); + var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetExternalFiles_VobSubIdxAndSubInDifferentDirectories_DoesNotPair() + { + // A same-named .idx and .sub split across the video folder and the internal + // metadata folder cannot be paired by ffprobe (it only looks next to the .idx), + // so the .sub must still be returned, but the orphaned .idx (no sibling .sub in + // its own directory) must be left out since it cannot be probed. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/My.Video.sub" }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { MetadataDirectoryPath + "/My.Video.idx" }); + + var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetExternalFiles_VobSubIdxAndSubWithMatchingLanguageFlag_SuppressesSub() + { + // A .idx/.sub pair sharing the same filename flags (e.g. a language token) should + // still pair and suppress the .sub, just like an unflagged pair. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/My.Video.en.idx", VideoDirectoryPath + "/My.Video.en.sub" }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + + var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetExternalFiles_VobSubIdxAndSubWithMismatchedNames_DoesNotPair() + { + // An .idx and .sub with different basenames (e.g. differing filename flags) are not + // a pair ffprobe would resolve. The .sub must still be returned, but the orphaned + // .idx (no same-named sibling .sub) must be left out since it cannot be probed. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.en.sub" }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + + var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase); + } + [Theory] [InlineData("https://url.com/My.Video.mkv")] [InlineData(VideoDirectoryPath)] // valid but no files found for this test diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs new file mode 100644 index 0000000000..cd7ae0f525 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs @@ -0,0 +1,113 @@ +using Emby.Naming.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Providers.MediaInfo; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo; + +public class ProbeProviderTests +{ + private readonly ProbeProvider _probeProvider; + + public ProbeProviderTests() + { + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsAny<string>())) + .Returns(MediaProtocol.File); + + // prep BaseItem and Video for calls made that expect managers + BaseItem.MediaSourceManager = mediaSourceManager.Object; + Video.RecordingsManager = Mock.Of<IRecordingsManager>(); + + _probeProvider = new ProbeProvider( + mediaSourceManager.Object, + Mock.Of<IMediaEncoder>(), + Mock.Of<IBlurayExaminer>(), + Mock.Of<ILocalizationManager>(), + Mock.Of<IChapterManager>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ISubtitleManager>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IFileSystem>(), + NullLoggerFactory.Instance, + new NamingOptions(), + Mock.Of<ILyricManager>(), + Mock.Of<IMediaAttachmentRepository>(), + Mock.Of<IMediaStreamRepository>()); + } + + [Fact] + public void HasChanged_NeverProbedVideo_ReturnsTrue() + { + // A probe that threw leaves the item like this while the refresh is stamped as done, and the + // file's modification time never changes afterwards, so nothing else would ask for a retry. + var item = new Episode { Path = "/media/show/S01E01.mkv" }; + + Assert.True(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Fact] + public void HasChanged_NeverProbedAudio_ReturnsTrue() + { + var item = new Audio { Path = "/media/music/track.flac" }; + + Assert.True(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Theory] + [InlineData(12345L, null)] + [InlineData(null, 2480000)] + [InlineData(12345L, 2480000)] + public void HasChanged_ProbedVideo_ReturnsFalse(long? runTimeTicks, int? totalBitrate) + { + var item = new Episode + { + Path = "/media/show/S01E01.mkv", + RunTimeTicks = runTimeTicks, + TotalBitrate = totalBitrate + }; + + Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Fact] + public void HasChanged_VirtualItemWithoutMediaInfo_ReturnsFalse() + { + var item = new Episode { Path = "/media/show/S01E01.mkv", IsVirtualItem = true }; + + Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Fact] + public void HasChanged_PlaceHolderWithoutMediaInfo_ReturnsFalse() + { + var item = new Episode { Path = "/media/show/S01E01.disc", IsPlaceHolder = true }; + + Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Fact] + public void HasChanged_ShortcutWithoutMediaInfo_ReturnsFalse() + { + // A .strm is only probed when remote content probing is enabled, so an empty one is expected. + var item = new Episode { Path = "/media/show/S01E01.strm", IsShortcut = true }; + + Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs new file mode 100644 index 0000000000..a73ed61a75 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Playlists; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Playlists; + +public sealed class PlaylistItemsProviderTests : IDisposable +{ + private const string AccentedFolder = "Música épica"; + private const string AccentedSong = "Canción.mp3"; + private const string AsciiSong = "Song.mp3"; + + private readonly string _libraryRoot; + private readonly PlaylistItemsProvider _sut; + + public PlaylistItemsProviderTests() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + _libraryRoot = Path.Combine(Path.GetTempPath(), "jellyfin-playlist-tests", Guid.NewGuid().ToString("N")); + var mediaFolder = Path.Combine(_libraryRoot, AccentedFolder); + Directory.CreateDirectory(mediaFolder); + File.WriteAllText(Path.Combine(mediaFolder, AccentedSong), string.Empty); + File.WriteAllText(Path.Combine(mediaFolder, AsciiSong), string.Empty); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(m => m.FindByPath(It.IsAny<string>(), It.IsAny<bool?>())) + .Returns((string path, bool? _) => new Audio { Id = Guid.NewGuid(), Path = path }); + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(m => m.MakeAbsolutePath(It.IsAny<string>(), It.IsAny<string>())) + .Returns((string folderPath, string filePath) => Path.GetFullPath(Path.Combine(folderPath, filePath))); + + _sut = new PlaylistItemsProvider(NullLogger<PlaylistItemsProvider>.Instance, libraryManager.Object, fileSystem.Object); + } + + [Fact] + public void GetM3uItems_Utf8Entries_ResolvesAccentedPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}"], 1); + + /// <summary> + /// Playlists written by Windows media players default to the local codepage rather than UTF-8. + /// Decoding those as UTF-8 mangles every accented character and loses the entry. + /// </summary> + [Fact] + public void GetM3uItems_LegacyCodepageEntries_ResolvesAccentedPaths() + => AssertResolved(Encoding.GetEncoding(1252), [$"{AccentedFolder}/{AccentedSong}"], 1); + + /// <summary> + /// The files on disk are stored precomposed (NFC), the playlist references them decomposed (NFD). + /// </summary> + [Fact] + public void GetM3uItems_DecomposedEntries_ResolvesAccentedPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}".Normalize(NormalizationForm.FormD)], 1); + + [Fact] + public void GetM3uItems_AsciiEntries_ResolvesPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AsciiSong}"], 1); + + [Fact] + public void GetM3uItems_Utf16Entries_ResolvesAccentedPaths() + => AssertResolved(Encoding.Unicode, [$"{AccentedFolder}/{AccentedSong}"], 1); + + [Fact] + public void GetM3uItems_MixedEntries_ResolvesEveryEntry() + => AssertResolved( + Encoding.GetEncoding(1252), + [$"{AccentedFolder}/{AccentedSong}", $"{AccentedFolder}/{AsciiSong}"], + 2); + + [Fact] + public void GetM3uItems_MissingFile_ResolvesNothing() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/Does not exist.mp3"], 0); + + public void Dispose() + { + if (Directory.Exists(_libraryRoot)) + { + Directory.Delete(_libraryRoot, true); + } + } + + private void AssertResolved(Encoding encoding, string[] entries, int expected) + { + var playlistPath = Path.Combine(_libraryRoot, "playlist.m3u"); + var content = new StringBuilder("#EXTM3U\n"); + foreach (var entry in entries) + { + content.Append("#EXTINF:1,Title\n").Append(entry).Append('\n'); + } + + File.WriteAllBytes( + playlistPath, + [.. encoding.GetPreamble(), .. encoding.GetBytes(content.ToString())]); + + using var stream = File.OpenRead(playlistPath); + var resolved = _sut.GetM3uItems(stream, playlistPath, [_libraryRoot]).ToList(); + + Assert.Equal(expected, resolved.Count); + } +} diff --git a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs index 8f5b1b3c48..ea762256db 100644 --- a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs +++ b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs @@ -1,5 +1,6 @@ using System; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; @@ -15,9 +16,23 @@ using Xunit; namespace Jellyfin.Providers.Tests.TV; -public class EpisodeMetadataServiceTests +// put tests that mock the static LibraryManager in the same collection to avoid test interference +[Collection("LibraryManagerTests")] +public sealed class EpisodeMetadataServiceTests : IDisposable { private readonly TestEpisodeMetadataService _service = new(); + private readonly ILibraryManager? _previousLibraryManager; + + public EpisodeMetadataServiceTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + BaseItem.LibraryManager = Mock.Of<ILibraryManager>(); + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager; + } [Fact] public void MergeData_ProviderSeasonOverridesPathDerivedSeason() @@ -88,6 +103,59 @@ public class EpisodeMetadataServiceTests Assert.Equal(1, target.Item.ParentIndexNumber); } + [Theory] + [InlineData(2, 1)] + [InlineData(22, 21)] + [InlineData(21, 2)] // e.g. "Series - S03E21 - E2 (1080p BluRay x265).mkv", where "E2" is the episode title + public void BeforeSave_ReversedEpisodeRange_ClearsIndexNumberEnd(int indexNumber, int indexNumberEnd) + { + var item = new Episode + { + IndexNumber = indexNumber, + IndexNumberEnd = indexNumberEnd + }; + + var updateType = _service.BeforeSave(item); + + // The episode number identifies the item, so it is kept and the impossible range is dropped + Assert.Equal(indexNumber, item.IndexNumber); + Assert.Null(item.IndexNumberEnd); + Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport)); + } + + [Fact] + public void BeforeSave_EpisodeRangeWithoutStart_ClearsIndexNumberEnd() + { + var item = new Episode + { + IndexNumber = null, + IndexNumberEnd = 2 + }; + + var updateType = _service.BeforeSave(item); + + Assert.Null(item.IndexNumberEnd); + Assert.Null(item.IndexNumber); + Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport)); + } + + [Theory] + [InlineData(1, 2)] // Regular multi episode file + [InlineData(1, 1)] // Degenerate but not contradictory + public void BeforeSave_ValidEpisodeRange_KeepsIndexNumberEnd(int indexNumber, int indexNumberEnd) + { + var item = new Episode + { + IndexNumber = indexNumber, + IndexNumberEnd = indexNumberEnd + }; + + _service.BeforeSave(item); + + Assert.Equal(indexNumber, item.IndexNumber); + Assert.Equal(indexNumberEnd, item.IndexNumberEnd); + } + private sealed class TestEpisodeMetadataService : EpisodeMetadataService { public TestEpisodeMetadataService() @@ -106,5 +174,10 @@ public class EpisodeMetadataServiceTests { MergeData(source, target, Array.Empty<MetadataField>(), replaceData, mergeMetadataSettings); } + + public ItemUpdateType BeforeSave(Episode item) + { + return BeforeSaveInternal(item, false, ItemUpdateType.None); + } } } diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs index 4c4dd5e92f..03bad3555e 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -1,6 +1,9 @@ +using System; +using System.Collections.Generic; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Model.Entities; using MediaBrowser.Providers.Plugins.Tmdb; +using TMDbLib.Objects.Search; using Xunit; namespace Jellyfin.Providers.Tests.Tmdb @@ -71,5 +74,182 @@ namespace Jellyfin.Providers.Tests.Tmdb Assert.False(new Movie().TryGetTmdbId(out var tmdbId)); Assert.Equal(0, tmdbId); } + + [Theory] + [InlineData("The Amityville Horror", "The Amityville Horror")] + [InlineData("WALL-E", "WALL E")] + // The interpunct is kept, it matches the TMDb title better than a space does. + [InlineData("WALL·E", "WALL·E")] + [InlineData("50-50", "50 50")] + [InlineData("A Christmas No. 1", "A Christmas No 1")] + // Vulgar fractions are numbers, dropping them turned "8½" into a search for "8". + [InlineData("8½", "8½")] + [InlineData("9½ Weeks", "9½ Weeks")] + [InlineData(" Léon: The Professional ", "Léon The Professional")] + public static void CleanName_Valid_Success(string name, string expected) + { + Assert.Equal(expected, TmdbUtils.CleanName(name)); + } + + [Theory] + [InlineData("WALL-E", "wall e")] + [InlineData("WALL·E", "wall e")] + [InlineData("WALL E", "wall e")] + [InlineData("8½", "8½")] + [InlineData("Ocean's Eleven", "ocean s eleven")] + [InlineData(null, "")] + [InlineData(" ", "")] + public static void NormalizeTitle_Valid_Success(string? title, string expected) + { + Assert.Equal(expected, TmdbUtils.NormalizeTitle(title)); + } + + [Theory] + // An unconfigured size fetches the original image, so it keeps the original resolution. + [InlineData(null, true)] + [InlineData("", true)] + [InlineData("original", true)] + [InlineData("Original", true)] + [InlineData("w500", false)] + [InlineData("original2", false)] + public static void IsOriginalImageSize_Valid_Success(string? size, bool expected) + { + Assert.Equal(expected, TmdbUtils.IsOriginalImageSize(size)); + } + + [Theory] + [MemberData(nameof(FindBestMatch_Movies_TestData))] + public static void FindBestMatch_Movies_PicksExpected(string description, string name, int year, IReadOnlyList<SearchMovie> results, int expectedId) + { + var match = TmdbUtils.FindBestMatch(results, name, year); + + Assert.NotNull(match); + Assert.True(expectedId == match.Id, $"{description}: expected {expectedId} but matched {match.Id}"); + } + + [Fact] + public static void FindBestMatch_Series_PicksMatchingFirstAirYear() + { + IReadOnlyList<SearchTv> results = + [ + Series(10042, "Doc", "Doc", 2001), + Series(101048, "Doc", "Doc", 2020), + Series(255055, "Doc", "Doc", 2025), + Series(2430, "Doc Martin", "Doc Martin", 2004) + ]; + + var match = TmdbUtils.FindBestMatch(results, "Doc", 2025); + + Assert.NotNull(match); + Assert.Equal(255055, match.Id); + } + + [Fact] + public static void FindBestMatch_NoResults_ReturnsNull() + { + Assert.Null(TmdbUtils.FindBestMatch(Array.Empty<SearchMovie>(), "Mulan", 2020)); + Assert.Null(TmdbUtils.FindBestMatch(Array.Empty<SearchTv>(), "Doc", 2025)); + Assert.Null(TmdbUtils.FindBestMatch((IReadOnlyList<SearchMovie>?)null, "Mulan", 2020)); + Assert.Null(TmdbUtils.FindBestMatch((IReadOnlyList<SearchTv>?)null, "Doc", 2025)); + } + + public static TheoryData<string, string, int, IReadOnlyList<SearchMovie>, int> FindBestMatch_Movies_TestData() + => new() + { + // TMDb's year parameter does not filter, so the remake and the original both come back and + // the wrong one is first. Results are in the order the live API returned them. + { + "Mulan (2020)", "Mulan", 2020, + [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020), Movie(752662, "Hua Mulan", "花木兰", 2020)], + 337401 + }, + { + "Mulan (1998)", "Mulan", 1998, + [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020), Movie(752662, "Hua Mulan", "花木兰", 2020)], + 10674 + }, + { + "Aladdin (2019)", "Aladdin", 2019, + [Movie(812, "Aladdin", "Aladdin", 1992), Movie(420817, "Aladdin", "Aladdin", 2019), Movie(602411, "Adventures of Aladdin", "Adventures of Aladdin", 2019)], + 420817 + }, + { + "The Lion King (2019)", "The Lion King", 2019, + [Movie(8587, "The Lion King", "The Lion King", 1994), Movie(420818, "The Lion King", "The Lion King", 2019)], + 420818 + }, + { + "The Amityville Horror (1979)", "The Amityville Horror", 1979, + [Movie(10065, "The Amityville Horror", "The Amityville Horror", 2005), Movie(11449, "The Amityville Horror", "The Amityville Horror", 1979)], + 11449 + }, + // A featurette outranks the film it belongs to. The interpunct must not stop "WALL-E" from + // matching "WALL·E", or the prefix match on the featurette wins. + { + "WALL-E (2008)", "WALL-E", 2008, + [Movie(877268, "WALL·E's Treasures & Trinkets", "WALL·E's Treasures & Trinkets", 2008), Movie(10681, "WALL·E", "WALL·E", 2008), Movie(10673, "Wall Street", "Wall Street", 1987)], + 10681 + }, + // The name only survives as "8" if the fraction is stripped, and then every 1963 result ties. + { + "8½ (1963)", "8½", 1963, + [Movie(422801, "Interpol Code 8", "国際秘密警察 指令第8号", 1963), Movie(520251, "Um 8 Uhr kommt Sadowski", "Um 8 Uhr kommt Sadowski", 1963), Movie(422, "8½", "8½", 1963)], + 422 + }, + // Matched on the original title, the localized one is unrecognizable. + { + "Ściany mają uszy (1966)", "Ściany mają uszy", 1966, + [Movie(1, "Something Else", "Something Else", 1966), Movie(2, "Walls Have Ears", "Ściany mają uszy", 1966)], + 2 + }, + // Regional release dates straddle the new year, so a year that is off by one still matches. + { + "Off by one year", "Some Movie", 2011, + [Movie(1, "Some Movie", "Some Movie", 2015), Movie(2, "Some Movie", "Some Movie", 2010)], + 2 + }, + // Nothing matches the name, so TMDb's own ordering is kept. + { + "A Christmas No. 1 (2021)", "A Christmas No. 1", 2021, + [Movie(878111, "A Christmas Number One", "A Christmas Number One", 2021), Movie(2, "Ten Hours for Christmas", "10 Horas para o Natal", 2021)], + 878111 + }, + // A title that matches always beats one that only shares the year. + { + "Title outranks year", "Some Movie", 2020, + [Movie(1, "A Different Movie", "A Different Movie", 2020), Movie(2, "Some Movie", "Some Movie", 1994)], + 2 + }, + // Without a year the title alone decides, and equally good titles keep TMDb's order. + { + "No year known", "Mulan", 0, + [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020)], + 10674 + }, + // An unparsable name must not throw or reorder anything. + { + "Empty name", " ", 2020, + [Movie(1, "Some Movie", "Some Movie", 1994), Movie(2, "Some Movie", "Some Movie", 2020)], + 1 + } + }; + + private static SearchMovie Movie(int id, string title, string originalTitle, int year) + => new() + { + Id = id, + Title = title, + OriginalTitle = originalTitle, + ReleaseDate = new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc) + }; + + private static SearchTv Series(int id, string name, string originalName, int year) + => new() + { + Id = id, + Name = name, + OriginalName = originalName, + FirstAirDate = new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc) + }; } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index 679e6d17e3..fd84cfb497 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; using Emby.Server.Implementations.Dto; +using Jellyfin.Data; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Drawing; @@ -138,6 +142,96 @@ public class DtoServiceTests Assert.Equal(9, dto.ChildCount); } + [Fact] + public void GetBaseItemDtos_NoUser_SkipsTheChildCountBatch() + { + // A child count is attached only to a user's dto, so with no user the batch is work whose + // result nothing reads - and it is a grouped count over every item, not a cheap one. + var (season, _) = BuildSeason(playedCount: 0, totalCount: 0, childCount: 10); + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + var dto = _dtoService.GetBaseItemDtos([season], options, user: null, skipVisibilityCheck: true)[0]; + + Assert.Null(dto.ChildCount); + _libraryManagerMock.Verify( + x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()), + Times.Never); + } + + [Fact] + public void GetBaseItemDtos_GroupedMoviesView_CountsEveryLibraryGroupedIntoIt() + { + // The view has no library of its own, so its count is the sum over the libraries the user + // grouped into it - including an untyped one, which the view also shows. + var user = new User("user", "auth-provider", "reset-provider"); + var grouped = BuildLibrary(CollectionType.movies); + var untyped = BuildLibrary(null); + var shows = BuildLibrary(CollectionType.tvshows); + var ungrouped = BuildLibrary(CollectionType.movies); + user.SetPreference(PreferenceKind.GroupedFolders, [grouped.Id, untyped.Id, shows.Id]); + + // A real root folder would resolve its children through the library it does not have here. + var rootFolder = new Mock<Folder>(); + rootFolder + .Setup(x => x.GetChildren(user, true, It.IsAny<InternalItemsQuery>())) + .Returns<User, bool, InternalItemsQuery>((_, _, _) => [grouped, untyped, shows, ungrouped]); + _libraryManagerMock.Setup(x => x.GetUserRootFolder()).Returns(rootFolder.Object); + + IReadOnlyList<Guid>? counted = null; + _libraryManagerMock + .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>())) + .Callback<IReadOnlyList<Guid>, User?>((ids, _) => counted = ids) + .Returns<IReadOnlyList<Guid>, User?>((ids, _) => ids.ToDictionary(id => id, _ => 4)); + + var view = new UserView { Id = Guid.NewGuid(), Name = "Movies", ViewType = CollectionType.movies }; + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + var dto = _dtoService.GetBaseItemDtos([view], options, user, skipVisibilityCheck: true)[0]; + + Assert.Equal(grouped.PhysicalFolderIds.Concat(untyped.PhysicalFolderIds), counted); + Assert.Equal(16, dto.ChildCount); + } + + [Fact] + public void GetBaseItemDtos_SubViewOfALibrary_DoesNotCountTheLibrary() + { + // A sub-view hangs off the library the view was built over, but it holds a query over it, + // not its children: counting the library would report every movie as "Continue Watching". + var user = new User("user", "auth-provider", "reset-provider"); + var library = BuildLibrary(CollectionType.movies); + _libraryManagerMock.Setup(x => x.GetItemById(library.Id)).Returns(library); + + // The fallback count a sub-view falls through to runs a query of its own. + _libraryManagerMock + .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns([]); + + var subView = new UserView + { + Id = Guid.NewGuid(), + Name = "Continue Watching", + ViewType = CollectionType.movieresume, + DisplayParentId = library.Id + }; + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + _dtoService.GetBaseItemDtos([subView], options, user, skipVisibilityCheck: true); + + _libraryManagerMock.Verify( + x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()), + Times.Never); + } + + private static CollectionFolder BuildLibrary(CollectionType? collectionType) + { + return new CollectionFolder + { + Id = Guid.NewGuid(), + CollectionType = collectionType, + PhysicalFolderIds = [Guid.NewGuid(), Guid.NewGuid()] + }; + } + private (Season Season, User User) BuildSeason(int playedCount, int totalCount, int childCount) { var user = new User("user", "auth-provider", "reset-provider"); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs new file mode 100644 index 0000000000..54fec0a0d3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Entities; + +public sealed class UserViewBuilderTests +{ + private static readonly User _user = new("view-filter-test", "provider", "reset"); + + [Fact] + public void Filter_IsPlayed_CountsAMovieWatchedOnAnAlternateVersionAsPlayed() + { + // The primary carries no played row of its own; the version that was watched is another file. + var onlyWatchedOnAlternate = new Movie { Id = Guid.NewGuid(), Name = "Watched as a second cut" }; + var watched = new Movie { Id = Guid.NewGuid(), Name = "Watched outright" }; + var unwatched = new Movie { Id = Guid.NewGuid(), Name = "Not watched" }; + + var items = new BaseItem[] { onlyWatchedOnAlternate, watched, unwatched }; + + var userDataManager = new Mock<IUserDataManager>(); + userDataManager + .Setup(m => m.GetUserData(_user, It.IsAny<BaseItem>())) + .Returns((User _, BaseItem item) => new UserItemData { Key = item.Id.ToString("N"), Played = item.Id.Equals(watched.Id) }); + userDataManager + .Setup(m => m.GetResumeUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), _user)) + .Returns(new Dictionary<Guid, VersionResumeData> + { + [onlyWatchedOnAlternate.Id] = new(Guid.NewGuid(), new UserItemData { Key = "alternate", Played = true }) + }); + + var libraryManager = new Mock<ILibraryManager>(); + + var played = UserViewBuilder.Filter( + items, + _user, + new InternalItemsQuery(_user) { IsPlayed = true }, + userDataManager.Object, + libraryManager.Object).ToList(); + + var unplayed = UserViewBuilder.Filter( + items, + _user, + new InternalItemsQuery(_user) { IsPlayed = false }, + userDataManager.Object, + libraryManager.Object).ToList(); + + // The alternate's playback settles the movie, exactly as the item's own dto reports it. + Assert.Equal([onlyWatchedOnAlternate.Id, watched.Id], played.Select(i => i.Id)); + Assert.Equal([unwatched.Id], unplayed.Select(i => i.Id)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs new file mode 100644 index 0000000000..cdb261de8d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.EntryPoints; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.EntryPoints; + +public class LibraryChangedNotifierTests +{ + // How long a test waits for the notifier's timer callback to run. Generous: the assertions are + // about a batch being sent at all, not about how promptly. + private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15); + + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly Mock<IServerConfigurationManager> _configurationManager = new(); + private readonly Mock<ISessionManager> _sessionManager = new(); + private readonly Mock<IUserManager> _userManager = new(); + private readonly Mock<IProviderManager> _providerManager = new(); + private readonly ServerConfiguration _configuration = new(); + + private int _flushCount; + + public LibraryChangedNotifierTests() + { + _configurationManager.SetupGet(e => e.Configuration).Returns(_configuration); + + // Reading the session list is the first thing a flush does, so it stands in for "a batch was + // sent" without having to mock a whole user library behind it. + _sessionManager.SetupGet(e => e.Sessions) + .Returns(() => + { + Interlocked.Increment(ref _flushCount); + return []; + }); + } + + [Fact] + public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow() + { + // Long enough that only the size cap can close the batch. + _configuration.LibraryUpdateDuration = 3600; + + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++) + { + RaiseItemUpdated(); + } + + Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + [Fact] + public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow() + { + // A scan changes items continuously. The window must run from the first change of a batch, or + // the batch never closes and holds every item it named alive for the length of the scan. + _configuration.LibraryUpdateDuration = 1; + + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0) + { + // Well below the window, and well below the size cap over the whole loop. + RaiseItemUpdated(); + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + private LibraryChangedNotifier CreateNotifier() + => new( + _libraryManager.Object, + _configurationManager.Object, + _sessionManager.Object, + _userManager.Object, + NullLogger<LibraryChangedNotifier>.Instance, + _providerManager.Object); + + // A folder passes the notifier's item filter without needing any of BaseItem's static services. + private void RaiseItemUpdated() + => _libraryManager.Raise( + e => e.ItemUpdated += null, + _libraryManager.Object, + new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } }); + + private async Task<bool> WaitForFlushAsync(int expected) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout) + { + if (Volatile.Read(ref _flushCount) >= expected) + { + return true; + } + + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + return false; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs new file mode 100644 index 0000000000..0274398f89 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.EntryPoints; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Session; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.EntryPoints; + +public class UserDataChangeNotifierTests +{ + // How long a test waits for the notifier's timer callback to run. Generous: the assertions are + // about a batch being sent at all, not about how promptly. + private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15); + + private readonly Mock<IUserDataManager> _userDataManager = new(); + private readonly Mock<ISessionManager> _sessionManager = new(); + private readonly Mock<IUserManager> _userManager = new(); + + private int _flushCount; + + public UserDataChangeNotifierTests() + { + _sessionManager + .Setup(e => e.SendMessageToUserSessions( + It.IsAny<System.Collections.Generic.List<Guid>>(), + SessionMessageType.UserDataChanged, + It.IsAny<Func<UserDataChangeInfo>>(), + It.IsAny<CancellationToken>())) + .Callback(() => Interlocked.Increment(ref _flushCount)) + .Returns(Task.CompletedTask); + } + + [Fact] + public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow() + { + // A scan changes user data continuously. The window must run from the first change of a batch, + // or the batch never closes and holds every item it named alive for the length of the scan. + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + var userId = Guid.NewGuid(); + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0) + { + // Well below the window, and well below the size cap over the whole loop. + RaiseUserDataSaved(userId); + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + private UserDataChangeNotifier CreateNotifier() + => new(_userDataManager.Object, _sessionManager.Object, _userManager.Object); + + // A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the + // notifier ignores outright. + private void RaiseUserDataSaved(Guid userId) + => _userDataManager.Raise( + e => e.UserDataSaved += null, + _userDataManager.Object, + new UserDataSaveEventArgs + { + UserId = userId, + SaveReason = UserDataSaveReason.UpdateUserRating, + Item = new Folder { Id = Guid.NewGuid() } + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs index 22667ee82d..b9ae16255e 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs @@ -1,7 +1,11 @@ using System; using System.Buffers; using System.IO; +using System.Net.WebSockets; +using System.Text; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Emby.Server.Implementations.HttpServer; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -48,6 +52,92 @@ namespace Jellyfin.Server.Implementations.Tests.HttpServer Assert.Throws<JsonException>(() => con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed)); } + [Fact] + public async Task ReceiveAsync_SocketTornDownWhileAnswering_RaisesClosedWithoutThrowing() + { + // The keep-alive watchdog can dispose a connection while the receive loop is + // answering a message on it. The failing answer must not escape into the request + // handler, as that would skip the Closed event the session needs to release it. + var socket = new DisposedOnSendWebSocket(Encoding.UTF8.GetBytes("{\"MessageType\":\"KeepAlive\"}")); + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), socket, null!, null!) + { + OnReceive = _ => Task.CompletedTask + }; + + var closed = false; + con.Closed += (_, _) => closed = true; + + await con.ReceiveAsync(TestContext.Current.CancellationToken); + + Assert.True(closed); + Assert.Equal(1, socket.SendAttempts); + } + + /// <summary> + /// A socket that hands out a single message and then behaves like a socket that was + /// disposed underneath the receive loop. + /// </summary> + internal sealed class DisposedOnSendWebSocket : WebSocket + { + private readonly byte[] _message; + private bool _received; + + public DisposedOnSendWebSocket(byte[] message) + { + _message = message; + } + + public int SendAttempts { get; private set; } + + public override WebSocketCloseStatus? CloseStatus => null; + + public override string? CloseStatusDescription => null; + + public override string? SubProtocol => null; + + public override WebSocketState State => SendAttempts == 0 ? WebSocketState.Open : WebSocketState.Closed; + + public override void Abort() + { + } + + public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) + => Task.CompletedTask; + + public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) + => Task.CompletedTask; + + public override void Dispose() + { + } + + public override ValueTask<ValueWebSocketReceiveResult> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_received, this); + + _received = true; + _message.CopyTo(buffer); + return ValueTask.FromResult(new ValueWebSocketReceiveResult(_message.Length, WebSocketMessageType.Text, true)); + } + + public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public override ValueTask SendAsync(ReadOnlyMemory<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) + => throw FailSend(); + + public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) + => throw FailSend(); + + private WebSocketException FailSend() + { + SendAttempts++; + return new WebSocketException( + WebSocketError.InvalidState, + "The WebSocket is in an invalid state ('Closed') for this operation. Valid states are: 'Open, CloseReceived'"); + } + } + internal sealed class BufferSegment : ReadOnlySequenceSegment<byte> { public BufferSegment(Memory<byte> memory) diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs new file mode 100644 index 0000000000..fd5f8e4160 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Emby.Server.Implementations.IO; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.IO; + +public class FileRefresherTests +{ + [Fact] + public async Task ProcessPathChanges_PathLookupThrows_StillRefreshesRemainingPaths() + { + var tempDir = Directory.CreateTempSubdirectory("filerefresher"); + try + { + // Ordered so the failing path is dequeued first. + var failingPath = Path.Combine(tempDir.FullName, "failing", "episode.mkv"); + var workingPath = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "working")).FullName; + + var workingItem = new Folder { Path = workingPath, Name = "working" }; + var workingItemFound = new TaskCompletionSource(); + + var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose); + libraryManager.Setup(x => x.FindByPath(failingPath, null)) + .Throws(new ObjectDisposedException("IServiceProvider")); + libraryManager.Setup(x => x.FindByPath(workingPath, null)) + .Returns(workingItem) + .Callback(() => workingItemFound.TrySetResult()); + + var configurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Loose); + configurationManager.Setup(x => x.Configuration) + .Returns(new ServerConfiguration { LibraryMonitorDelay = 1 }); + + using var refresher = new FileRefresher( + failingPath, + configurationManager.Object, + libraryManager.Object, + NullLogger.Instance); + refresher.AddPath(workingPath); + + await workingItemFound.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + libraryManager.Verify(x => x.FindByPath(failingPath, null), Times.Once); + } + finally + { + tempDir.Delete(true); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs index 6cadfacce8..b39ca83483 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs @@ -100,6 +100,41 @@ public partial class ManagedFileSystemTests Assert.Equal(expectedFileName, _sut.GetValidFilename(filename)); } + [Theory] + [InlineData("/media", "/media/tv", true)] + [InlineData("/media", "/media/tv/show/episode.mkv", true)] + [InlineData("/media/", "/media/tv", true)] + [InlineData("/", "/media", true)] + [InlineData("/media", "/media", false)] + [InlineData("/media", "/media/", true)] + [InlineData("/media", "/data/media/tv", false)] + [InlineData("/media", "/mediastuff/tv", false)] + [InlineData("/data/media", "/data/media/tv", true)] + [InlineData("/media/tv", "/media", false)] + [InlineData("/MEDIA", "/media/tv", false)] + public void ContainsSubPath_Unix_ReturnsExpected(string parentPath, string path, bool expected) + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix-only test"); + + Assert.Equal(expected, _sut.ContainsSubPath(parentPath, path)); + } + + [Theory] + [InlineData(@"C:\media", @"C:\media\tv", true)] + [InlineData(@"C:\media\", @"C:\media\tv", true)] + [InlineData(@"C:\", @"C:\media", true)] + [InlineData(@"C:\media", @"C:\media", false)] + [InlineData(@"C:\media", @"C:\data\media\tv", false)] + [InlineData(@"C:\media", @"C:\mediastuff\tv", false)] + [InlineData(@"C:\MEDIA", @"C:\media\tv", true)] + [InlineData(@"C:\media", @"C:\media/tv", true)] + public void ContainsSubPath_Windows_ReturnsExpected(string parentPath, string path, bool expected) + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows-only test"); + + Assert.Equal(expected, _sut.ContainsSubPath(parentPath, path)); + } + [Fact] public void GetFileInfo_DanglingSymlink_ExistsFalse() { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs new file mode 100644 index 0000000000..abe1e59496 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the user data rows <see cref="BaseItemMapper"/> hands the domain item. A domain item is +/// held for as long as its folder holds it, so a row that still points back at the entity it was +/// read with would keep that entity - and everything loaded alongside it - alive with it. +/// </summary> +public class BaseItemMapperUserDataTests +{ + [Fact] + public void Map_CopiesUserDataWithoutTheEntityGraphBehindIt() + { + var itemId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var user = new User("someone", "Default", "Default"); + var entity = new BaseItemEntity { Id = itemId, Type = "MediaBrowser.Controller.Entities.TV.Episode" }; + + var row = new UserData + { + ItemId = itemId, + Item = entity, + UserId = userId, + User = user, + CustomDataKey = "key", + PlayCount = 3, + PlaybackPositionTicks = 1234, + IsFavorite = true, + Played = true, + Rating = 7.5, + LastPlayedDate = new DateTime(2026, 9, 8, 0, 0, 0, DateTimeKind.Utc), + AudioStreamIndex = 1, + SubtitleStreamIndex = 2, + Likes = true + }; + + entity.UserData = [row]; + + var dto = BaseItemMapper.Map(entity, new Folder(), null); + + var mapped = Assert.Single(dto.UserData); + Assert.Null(mapped.Item); + Assert.Null(mapped.User); + + // The values callers actually read still come through. + Assert.Equal(itemId, mapped.ItemId); + Assert.Equal(userId, mapped.UserId); + Assert.Equal("key", mapped.CustomDataKey); + Assert.Equal(3, mapped.PlayCount); + Assert.Equal(1234, mapped.PlaybackPositionTicks); + Assert.True(mapped.IsFavorite); + Assert.True(mapped.Played); + Assert.Equal(7.5, mapped.Rating); + Assert.Equal(row.LastPlayedDate, mapped.LastPlayedDate); + Assert.Equal(1, mapped.AudioStreamIndex); + Assert.Equal(2, mapped.SubtitleStreamIndex); + Assert.True(mapped.Likes); + } + + [Fact] + public void Map_WithoutUserData_YieldsAnEmptyCollection() + { + var entity = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "MediaBrowser.Controller.Entities.Folder" + }; + + var dto = BaseItemMapper.Map(entity, new Folder(), null); + + Assert.Empty(dto.UserData); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs new file mode 100644 index 0000000000..298340d1b0 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs @@ -0,0 +1,215 @@ +using System; +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.Querying; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// The by-name listings count what a cleaned value is attached to by joining ItemValuesMap to +/// BaseItems. One item can reach the same clean value through more than one value row, so the +/// join has to be counted per distinct item; counting rows reports a multiple of the truth. +/// </summary> +public sealed class BaseItemRepositoryByNameItemCountsTests : SqliteDbTestFixture +{ + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryByNameItemCountsTests() + { + _itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(_itemTypeLookup); + } + + [Fact] + public void GetAllArtists_AlbumCreditedAsArtistAndAlbumArtist_CountsTheAlbumOnce() + { + // GetAllArtists spans both credit types, so an album whose artist is also its album artist + // reaches the one clean value through two rows. + SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.AlbumArtist); + + var result = _repository.GetAllArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + Assert.Equal(1, counts.ItemCount); + } + + [Fact] + public void GetAlbumArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() + { + // The shape that actually reaches users: only (Type, Value) is unique, so two differently + // cased credits of one type both clean down to a single name and both map the album. + SeedArtistWithAlbum(ItemValueType.AlbumArtist, ItemValueType.AlbumArtist); + + var result = _repository.GetAlbumArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + } + + [Fact] + public void GetArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() + { + SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.Artist); + + var result = _repository.GetArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + } + + [Theory] + [InlineData(BaseItemKind.Book)] + [InlineData(BaseItemKind.BoxSet)] + public void GetGenres_TaggedBookOrBoxSet_CountsIt(BaseItemKind kind) + { + // The listing used to dispatch only nine of the eleven counted types, so a genre on a book + // or a box set read as zero in a list and as one on the genre's own page. + SeedGenreWith(kind); + + var result = _repository.GetGenres(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, kind == BaseItemKind.Book ? counts.BookCount : counts.BoxSetCount); + Assert.Equal(1, counts.ItemCount); + } + + /// <summary> + /// Seeds one genre carried by a single item of the given kind. + /// </summary> + /// <param name="kind">The kind of the tagged item.</param> + private void SeedGenreWith(BaseItemKind kind) + { + const string Name = "Reference"; + const string CleanName = "reference"; + + using var ctx = CreateDbContext(); + + var genreId = Guid.Parse("dddddddd-0000-0000-0000-000000000001"); + var taggedId = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001"); + + ctx.BaseItems.Add(new BaseItemEntity + { + Id = genreId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre], + Name = Name, + CleanName = CleanName, + PresentationUniqueKey = genreId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }); + + var tagged = new BaseItemEntity + { + Id = taggedId, + Type = _itemTypeLookup.BaseItemKindNames[kind], + Name = "Tagged", + CleanName = "tagged", + PresentationUniqueKey = taggedId.ToString("N"), + IsFolder = false, + IsVirtualItem = false + }; + ctx.BaseItems.Add(tagged); + + var itemValue = new ItemValue + { + ItemValueId = Guid.Parse("ffffffff-0000-0000-0000-000000000001"), + Type = ItemValueType.Genre, + Value = Name, + CleanValue = CleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = taggedId, + ItemValueId = itemValue.ItemValueId, + Item = tagged, + ItemValue = itemValue + }); + + ctx.SaveChanges(); + } + + private static InternalItemsQuery CreateCountingQuery() + { + return new InternalItemsQuery(new User("test", "auth", "reset")) + { + DtoOptions = new DtoOptions(true) { Fields = [ItemFields.ItemCounts] } + }; + } + + /// <summary> + /// Seeds one artist and a single album mapped to that artist's clean name through two value + /// rows of the given types. + /// </summary> + /// <param name="first">The type of the first value row.</param> + /// <param name="second">The type of the second value row.</param> + private void SeedArtistWithAlbum(ItemValueType first, ItemValueType second) + { + const string Name = "Tangerine Dream"; + const string CleanName = "tangerine dream"; + + using var ctx = CreateDbContext(); + + var artistId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + var albumId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"); + + ctx.BaseItems.Add(new BaseItemEntity + { + Id = artistId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], + Name = Name, + CleanName = CleanName, + PresentationUniqueKey = artistId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }); + + var album = new BaseItemEntity + { + Id = albumId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum], + Name = "Phaedra", + CleanName = "phaedra", + PresentationUniqueKey = albumId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }; + ctx.BaseItems.Add(album); + + var types = new[] { first, second }; + for (var i = 0; i < types.Length; i++) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}"), + Type = types[i], + // Distinct values, one clean name: exactly what the unique index permits. + Value = i == 0 ? Name : Name.ToUpperInvariant(), + CleanValue = CleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = albumId, + ItemValueId = itemValue.ItemValueId, + Item = album, + ItemValue = itemValue + }); + } + + ctx.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs new file mode 100644 index 0000000000..5e045e9f83 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the children query the library scan runs against a folder: a version merged by hand is +/// hidden from ordinary queries, but the scan has to see it or it takes the row for a new item and +/// recreates it, splitting the version group apart again. +/// </summary> +public sealed class BaseItemRepositoryChildrenTests : SqliteDbTestFixture +{ + private static readonly Guid _folderId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _primaryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static readonly Guid _mergedVersionId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + private static readonly Guid _ownedVersionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + private readonly BaseItemRepository _repository; + + public BaseItemRepositoryChildrenTests() + { + var itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(itemTypeLookup); + + var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + using var ctx = CreateDbContext(); + ctx.BaseItems.Add(new BaseItemEntity + { + Id = _folderId, + Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!, + Name = "Movies", + Path = "/movies", + IsFolder = true + }); + ctx.BaseItems.Add(CreateMovie(_primaryId, movieTypeName!, "Big Buck Bunny", "/media1/Big Buck Bunny/bbb-1080p.mp4", null, null)); + ctx.BaseItems.Add(CreateMovie(_mergedVersionId, movieTypeName!, "Big Buck Bunny", "/media2/Big Buck Bunny/bbb-2160p.mp4", _primaryId, null)); + ctx.BaseItems.Add(CreateMovie(_ownedVersionId, movieTypeName!, "Big Buck Bunny - 720p", "/media1/Big Buck Bunny/bbb-720p.mp4", _primaryId, _primaryId)); + ctx.SaveChanges(); + } + + [Fact] + public void GetItemList_ChildrenOfFolder_ExcludesAlternateVersionsByDefault() + { + var result = _repository.GetItemList(new InternalItemsQuery { ParentId = _folderId }); + + var item = Assert.Single(result); + Assert.Equal(_primaryId, item.Id); + } + + [Fact] + public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_KeepsMergedVersion() + { + var result = _repository.GetItemList(new InternalItemsQuery + { + ParentId = _folderId, + IncludeAlternateVersions = true + }); + + Assert.Equal(2, result.Count); + Assert.Contains(result, i => i.Id.Equals(_primaryId)); + Assert.Contains(result, i => i.Id.Equals(_mergedVersionId)); + } + + [Fact] + public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_StillExcludesOwnedVersion() + { + // A version stored next to the file it belongs to is owned by its primary and is never + // resolved on its own, so the scan must not see it as a child of the folder either. + var result = _repository.GetItemList(new InternalItemsQuery + { + ParentId = _folderId, + IncludeAlternateVersions = true + }); + + Assert.DoesNotContain(result, i => i.Id.Equals(_ownedVersionId)); + } + + private static BaseItemEntity CreateMovie(Guid id, string typeName, string name, string path, Guid? primaryVersionId, Guid? ownerId) + { + return new BaseItemEntity + { + Id = id, + Type = typeName, + Name = name, + Path = path, + ParentId = _folderId, + TopParentId = _folderId, + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"), + PrimaryVersionId = primaryVersionId, + OwnerId = ownerId, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs index 535961a66c..9238ec9fd1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs @@ -13,13 +13,18 @@ namespace Jellyfin.Server.Implementations.Tests.Item; public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture { + private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private readonly BaseItemRepository _repository; private readonly string _movieTypeName; + private readonly string _folderTypeName; public BaseItemRepositoryGroupingTests() { var itemTypeLookup = new ItemTypeLookup(); _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]; _repository = CreateBaseItemRepository(itemTypeLookup); } @@ -67,6 +72,118 @@ public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture Assert.Equal(firstId, item.Id); } + [Fact] + public void GetItemList_LibraryWithoutThePrimaryOfTheGroup_KeepsTheVersionVisible() + { + var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + var sameLibraryPrimaryId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + var sameLibraryVersionId = Guid.Parse("66666666-6666-6666-6666-666666666666"); + + SeedCrossLibraryGroup(primaryId, versionId, sameLibraryPrimaryId, sameLibraryVersionId); + + var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId)); + + // The version stands in for the group in the library it lives in, because its primary is in + // a library of its own; a group merged inside this library still collapses onto its primary. + Assert.Contains(result, i => i.Id.Equals(versionId)); + Assert.Contains(result, i => i.Id.Equals(sameLibraryPrimaryId)); + Assert.DoesNotContain(result, i => i.Id.Equals(sameLibraryVersionId)); + Assert.DoesNotContain(result, i => i.Id.Equals(primaryId)); + } + + [Fact] + public void GetItemList_LibraryHoldingThePrimary_ReturnsThePrimary() + { + var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + SeedCrossLibraryGroup(primaryId, versionId); + + var result = _repository.GetItemList(CreateLibraryQuery(_movie4KLibraryId)); + + var item = Assert.Single(result); + Assert.Equal(primaryId, item.Id); + } + + [Fact] + public void GetItemList_BothLibrariesOfACrossLibraryGroup_ReturnsItOnce() + { + var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + SeedCrossLibraryGroup(primaryId, versionId); + + var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId, _movie4KLibraryId)); + + // With both libraries in scope the presentation key grouping collapses the version. + var item = Assert.Single(result); + Assert.Equal(primaryId, item.Id); + } + + [Fact] + public void GetItems_LibraryWithoutThePrimaryOfTheGroup_CountsWhatItLists() + { + var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + SeedCrossLibraryGroup(primaryId, versionId); + + var listed = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId)).Count; + + var query = CreateLibraryQuery(_movieLibraryId); + query.EnableTotalRecordCount = true; + query.Limit = 1; + + // The total the client pages against has to agree with the listing. + Assert.Equal(1, listed); + Assert.Equal(listed, _repository.GetItems(query).TotalRecordCount); + } + + private static InternalItemsQuery CreateLibraryQuery(params Guid[] topParentIds) + { + return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie], + TopParentIds = topParentIds + }; + } + + private void SeedCrossLibraryGroup( + Guid primaryId, + Guid versionId, + Guid? sameLibraryPrimaryId = null, + Guid? sameLibraryVersionId = null) + { + using var ctx = CreateDbContext(); + ctx.BaseItems.Add(CreateFolderEntity(_movieLibraryId, "Movies")); + ctx.BaseItems.Add(CreateFolderEntity(_movie4KLibraryId, "Movies-4K")); + + // The 4K version heads the group and lives in a library of its own. + ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie - 4K", primaryId.ToString("N"), null, _movie4KLibraryId)); + ctx.BaseItems.Add(CreateMovieEntity(versionId, "Movie", primaryId.ToString("N"), primaryId, _movieLibraryId)); + + if (sameLibraryPrimaryId.HasValue && sameLibraryVersionId.HasValue) + { + ctx.BaseItems.Add(CreateMovieEntity(sameLibraryPrimaryId.Value, "Other - 4K", sameLibraryPrimaryId.Value.ToString("N"), null, _movieLibraryId)); + ctx.BaseItems.Add(CreateMovieEntity(sameLibraryVersionId.Value, "Other", sameLibraryPrimaryId.Value.ToString("N"), sameLibraryPrimaryId.Value, _movieLibraryId)); + } + + ctx.SaveChanges(); + } + + private BaseItemEntity CreateFolderEntity(Guid id, string name) + { + return new BaseItemEntity + { + Id = id, + Type = _folderTypeName, + Name = name, + Path = "/" + name, + IsFolder = true + }; + } + private static InternalItemsQuery CreateQuery() { // IncludeOwnedItems keeps the alternate version rows in the query so the @@ -78,13 +195,15 @@ public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture }; } - private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId) + private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId, Guid? libraryId = null) { return new BaseItemEntity { Id = id, Type = _movieTypeName, Name = name, + ParentId = libraryId, + TopParentId = libraryId, PresentationUniqueKey = presentationKey, PrimaryVersionId = primaryVersionId, MediaType = "Video", diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs new file mode 100644 index 0000000000..039693c432 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs @@ -0,0 +1,218 @@ +using System; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture +{ + private readonly BaseItemRepository _repository; + private readonly string _audioTypeName; + private readonly string _movieTypeName; + + public BaseItemRepositoryItemValueTests() + { + var itemTypeLookup = new ItemTypeLookup(); + _audioTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + _repository = CreateBaseItemRepository(itemTypeLookup); + } + + [Fact] + public void GetQueryFiltersLegacy_GroupsAndFiltersItemValues() + { + var firstItem = CreateMovieEntity(Guid.NewGuid(), "First"); + var secondItem = CreateMovieEntity(Guid.NewGuid(), "Second"); + var excludedItem = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Excluded Audio", + MediaType = "Audio", + IsMovie = false, + IsFolder = false, + IsVirtualItem = false + }; + var firstTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Alpha", + CleanValue = "alpha" + }; + var duplicateTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "alpha", + CleanValue = "alpha" + }; + var secondTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Beta", + CleanValue = "beta" + }; + var genre = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = "Genre Leak", + CleanValue = "genre leak" + }; + var excludedTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Excluded Tag", + CleanValue = "excluded tag" + }; + var excludedGenre = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = "Excluded Genre", + CleanValue = "excluded genre" + }; + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(firstItem, secondItem, excludedItem); + context.ItemValues.AddRange(firstTag, duplicateTag, secondTag, genre, excludedTag, excludedGenre); + context.ItemValuesMap.AddRange( + CreateMap(firstItem, firstTag), + CreateMap(firstItem, duplicateTag), + CreateMap(secondItem, secondTag), + CreateMap(firstItem, genre), + CreateMap(excludedItem, excludedTag), + CreateMap(excludedItem, excludedGenre)); + context.SaveChanges(); + } + + var result = _repository.GetQueryFiltersLegacy(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal(["Alpha", "Beta"], result.Tags); + Assert.Equal(["Genre Leak"], result.Genres); + } + + [Fact] + public void GetTagNames_GroupsAndFiltersItemValues() + { + var movie = CreateMovieEntity(Guid.NewGuid(), "Movie"); + var otherMovie = CreateMovieEntity(Guid.NewGuid(), "Other Movie"); + var audio = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Excluded Audio", + MediaType = "Audio", + IsMovie = false, + IsFolder = false, + IsVirtualItem = false + }; + var tag = CreateItemValue(ItemValueType.Tags, "Alpha", "alpha"); + var duplicateTag = CreateItemValue(ItemValueType.Tags, "alpha", "alpha"); + var otherTag = CreateItemValue(ItemValueType.Tags, "Beta", "beta"); + var inheritedTag = CreateItemValue(ItemValueType.InheritedTags, "Inherited", "inherited"); + var genre = CreateItemValue(ItemValueType.Genre, "Genre Leak", "genre leak"); + var excludedTag = CreateItemValue(ItemValueType.Tags, "Excluded Tag", "excluded tag"); + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(movie, otherMovie, audio); + context.ItemValues.AddRange(tag, duplicateTag, otherTag, inheritedTag, genre, excludedTag); + context.ItemValuesMap.AddRange( + CreateMap(movie, tag), + CreateMap(movie, duplicateTag), + CreateMap(otherMovie, otherTag), + CreateMap(movie, inheritedTag), + CreateMap(movie, genre), + CreateMap(audio, excludedTag)); + context.SaveChanges(); + } + + var result = _repository.GetTagNames(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal(["Alpha", "Beta"], result); + } + + [Fact] + public void GetGenreNames_GroupsAndFiltersMappedItemValues() + { + var movie = CreateMovieEntity(Guid.NewGuid(), "Movie"); + var audio = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Audio", + MediaType = "Audio", + IsFolder = false, + IsVirtualItem = false + }; + var movieGenre = CreateItemValue(ItemValueType.Genre, "Movie Genre", "movie genre"); + var duplicateMovieGenre = CreateItemValue(ItemValueType.Genre, "movie genre", "movie genre"); + var musicGenre = CreateItemValue(ItemValueType.Genre, "Music Genre", "music genre"); + var orphanedGenre = CreateItemValue(ItemValueType.Genre, "Orphaned Genre", "orphaned genre"); + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(movie, audio); + context.ItemValues.AddRange(movieGenre, duplicateMovieGenre, musicGenre, orphanedGenre); + context.ItemValuesMap.AddRange( + CreateMap(movie, movieGenre), + CreateMap(movie, duplicateMovieGenre), + CreateMap(audio, musicGenre)); + context.SaveChanges(); + } + + Assert.Equal(["Movie Genre"], _repository.GetGenreNames()); + Assert.Equal(["Music Genre"], _repository.GetMusicGenreNames()); + } + + private BaseItemEntity CreateMovieEntity(Guid id, string name) + { + return new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } + + private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue) + { + return new ItemValueMap + { + ItemId = item.Id, + ItemValueId = itemValue.ItemValueId, + Item = item, + ItemValue = itemValue + }; + } + + private static ItemValue CreateItemValue(ItemValueType type, string value, string cleanValue) + { + return new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = value, + CleanValue = cleanValue + }; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs new file mode 100644 index 0000000000..a9548a6d13 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the isPlayed filter over items with alternate versions: playback is recorded against the +/// version that was actually played, so the played state belongs to the version group rather than to +/// the row that happens to carry it. +/// </summary> +public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture +{ + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + + private readonly BaseItemRepository _repository; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + private readonly Guid _playedViaAlternate = Guid.NewGuid(); + private readonly Guid _playedOnPrimary = Guid.NewGuid(); + private readonly Guid _unplayedWithAlternate = Guid.NewGuid(); + private readonly Guid _unplayedWithoutAlternate = Guid.NewGuid(); + + private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid(); + private readonly Guid _unplayedSeries = Guid.NewGuid(); + private readonly Guid _seriesPlayedAcrossVersions = Guid.NewGuid(); + private readonly Guid _partiallyPlayedSeries = Guid.NewGuid(); + + public BaseItemRepositoryPlayedVersionTests() + { + using (var context = CreateDbContext()) + { + Seed(context); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void IsPlayed_CountsAMoviePlayedThroughItsAlternateVersion() + { + Assert.Equal( + new HashSet<Guid> { _playedOnPrimary, _playedViaAlternate }, + Ids(BaseItemKind.Movie, isPlayed: true)); + } + + [Fact] + public void IsUnplayed_DropsAMoviePlayedThroughItsAlternateVersion() + { + Assert.Equal( + new HashSet<Guid> { _unplayedWithAlternate, _unplayedWithoutAlternate }, + Ids(BaseItemKind.Movie, isPlayed: false)); + } + + [Fact] + public void IsPlayed_KeepsAPlayedPrimaryWhoseAlternateHasNoRowOfItsOwn() + { + Assert.Contains(_playedOnPrimary, Ids(BaseItemKind.Movie, isPlayed: true)); + } + + [Fact] + public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() + { + Assert.Equal( + new HashSet<Guid> { _seriesPlayedViaAlternate, _seriesPlayedAcrossVersions }, + Ids(BaseItemKind.Series, isPlayed: true)); + Assert.Equal( + new HashSet<Guid> { _unplayedSeries, _partiallyPlayedSeries }, + Ids(BaseItemKind.Series, isPlayed: false)); + } + + [Fact] + public void GetIsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() + { + Assert.True(_repository.GetIsPlayed(_user, _seriesPlayedViaAlternate, true)); + Assert.False(_repository.GetIsPlayed(_user, _unplayedSeries, true)); + } + + [Fact] + public void IsResumable_DropsASeriesWhoseLastEpisodeWasPlayedThroughAnAlternateVersion() + { + var resumable = _repository.GetItemIdsList(new InternalItemsQuery(_user) { IsResumable = true }); + + // Nothing is left to watch, so the series is not half finished. + Assert.DoesNotContain(_seriesPlayedAcrossVersions, resumable); + Assert.Contains(_partiallyPlayedSeries, resumable); + } + + private HashSet<Guid> Ids(BaseItemKind kind, bool isPlayed) + => _repository + .GetItemList(new InternalItemsQuery(_user) + { + IncludeItemTypes = [kind], + IsPlayed = isPlayed + }) + .Select(i => i.Id) + .ToHashSet(); + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + // Only the alternate carries the played row, which is what playing that version records. + AddMovieWithAlternate(context, _playedViaAlternate, "A", playedPrimary: false, playedAlternate: true); + AddMovieWithAlternate(context, _playedOnPrimary, "B", playedPrimary: true, playedAlternate: false); + AddMovieWithAlternate(context, _unplayedWithAlternate, "C", playedPrimary: false, playedAlternate: false); + AddItem(context, _unplayedWithoutAlternate, MovieType, "D"); + + AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true); + AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false); + + AddSeriesWithTwoEpisodes(context, _seriesPlayedAcrossVersions, "G", secondPlayedViaAlternate: true); + AddSeriesWithTwoEpisodes(context, _partiallyPlayedSeries, "H", secondPlayedViaAlternate: false); + + context.SaveChanges(); + } + + private void AddMovieWithAlternate(JellyfinDbContext context, Guid primaryId, string name, bool playedPrimary, bool playedAlternate) + { + AddItem(context, primaryId, MovieType, name); + AddAlternateVersion(context, primaryId, MovieType, $"{name} 4K", playedAlternate); + + if (playedPrimary) + { + AddPlayedUserData(context, primaryId); + } + } + + private void AddSeriesWithAlternateEpisode(JellyfinDbContext context, Guid seriesId, string name, bool playedAlternate) + { + var episodeId = Guid.NewGuid(); + + AddSeriesFolder(context, seriesId, name); + + AddItem(context, episodeId, EpisodeType, $"{name} 1"); + context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + + AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate); + } + + // A watched first episode plus a second one that is either watched as its alternate version or not + // watched at all, which is what separates a finished series from a half watched one. + private void AddSeriesWithTwoEpisodes(JellyfinDbContext context, Guid seriesId, string name, bool secondPlayedViaAlternate) + { + AddSeriesFolder(context, seriesId, name); + + var firstId = Guid.NewGuid(); + AddItem(context, firstId, EpisodeType, $"{name} 1"); + context.AncestorIds.Add(new AncestorId { ItemId = firstId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + AddPlayedUserData(context, firstId); + + var secondId = Guid.NewGuid(); + AddItem(context, secondId, EpisodeType, $"{name} 2"); + context.AncestorIds.Add(new AncestorId { ItemId = secondId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + AddAlternateVersion(context, secondId, EpisodeType, $"{name} 2 4K", secondPlayedViaAlternate); + } + + private void AddSeriesFolder(JellyfinDbContext context, Guid seriesId, string name) + => context.BaseItems.Add(new BaseItemEntity + { + Id = seriesId, + Type = SeriesType, + Name = name, + SortName = name, + PresentationUniqueKey = seriesId.ToString("N"), + IsFolder = true + }); + + private void AddItem(JellyfinDbContext context, Guid id, string type, string name) + => context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = type, + Name = name, + SortName = name, + PresentationUniqueKey = id.ToString("N") + }); + + private void AddAlternateVersion(JellyfinDbContext context, Guid primaryId, string type, string name, bool played) + { + var alternateId = Guid.NewGuid(); + + // An alternate presents under its primary's key, which is what collapses the group in listings. + context.BaseItems.Add(new BaseItemEntity + { + Id = alternateId, + Type = type, + Name = name, + SortName = name, + PresentationUniqueKey = primaryId.ToString("N"), + PrimaryVersionId = primaryId + }); + + if (played) + { + AddPlayedUserData(context, alternateId); + } + } + + private void AddPlayedUserData(JellyfinDbContext context, Guid itemId) + => context.UserData.Add(new UserData + { + ItemId = itemId, + UserId = _user.Id, + CustomDataKey = itemId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index fea743f08e..787bb24150 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; @@ -12,6 +15,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -25,6 +29,8 @@ public sealed class ItemCountServiceTests : IDisposable private readonly DbContextOptions<JellyfinDbContext> _dbOptions; private readonly IApplicationPaths _applicationPaths; private readonly ItemCountService _service; + private int _contextsCreated; + private List<string>? _capturedSql; public ItemCountServiceTests() { @@ -35,6 +41,7 @@ public sealed class ItemCountServiceTests : IDisposable _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() .UseSqlite(_connection) + .LogTo(CaptureStatement, LogLevel.Information) .Options; using (var context = CreateDbContext()) @@ -43,7 +50,11 @@ public sealed class ItemCountServiceTests : IDisposable } var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContext()).Returns(() => + { + _contextsCreated++; + return CreateDbContext(); + }); var queryHelpers = new Mock<IItemQueryHelpers>(); queryHelpers @@ -53,9 +64,25 @@ public sealed class ItemCountServiceTests : IDisposable It.IsAny<InternalItemsQuery>())) .Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query); + var typeLookup = new Mock<IItemTypeLookup>(); + typeLookup.Setup(l => l.BaseItemKindNames).Returns(new Dictionary<BaseItemKind, string> + { + [BaseItemKind.Movie] = "Movie", + [BaseItemKind.Series] = "Series", + [BaseItemKind.Episode] = "Episode", + [BaseItemKind.MusicAlbum] = "MusicAlbum", + [BaseItemKind.MusicArtist] = "MusicArtist", + [BaseItemKind.MusicVideo] = "MusicVideo", + [BaseItemKind.Audio] = "Audio", + [BaseItemKind.Trailer] = "Trailer", + [BaseItemKind.BoxSet] = "BoxSet", + [BaseItemKind.Book] = "Book", + [BaseItemKind.LiveTvProgram] = "LiveTvProgram" + }); + _service = new ItemCountService( factory.Object, - new Mock<IItemTypeLookup>().Object, + typeLookup.Object, queryHelpers.Object); } @@ -64,6 +91,14 @@ public sealed class ItemCountServiceTests : IDisposable _connection.Dispose(); } + private void CaptureStatement(string message) + { + if (_capturedSql is not null && message.Contains("SELECT", StringComparison.Ordinal)) + { + _capturedSql.Add(message[message.IndexOf("SELECT", StringComparison.Ordinal)..]); + } + } + [Fact] public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit() { @@ -166,6 +201,201 @@ public sealed class ItemCountServiceTests : IDisposable } [Fact] + public void GetCounts_PlayedAlternateVersion_CountThePrimaryAsPlayed() + { + var user = new User("alt-version-test", "provider", "reset"); + var seriesId = Guid.NewGuid(); + var primaryId = Guid.NewGuid(); + var alternateId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + var series = CreateItem(seriesId); + series.PresentationUniqueKey = "alt-version-series"; + context.BaseItems.Add(series); + + context.BaseItems.Add(CreateLeaf(primaryId)); + var alternate = CreateLeaf(alternateId); + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + context.SaveChanges(); + + // Only the primary is counted as a leaf, as ApplyAccessFiltering leaves it in production. + AddAncestor(context, primaryId, seriesId); + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = primaryId, + ChildId = alternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + // The file that was watched is the alternate, so the primary carries no played row. + context.UserData.Add(new UserData + { + ItemId = alternateId, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + // The per-item paths have to agree with the batch one, which the DTO uses interchangeably. + Assert.Equal(1, _service.GetPlayedCount(filter, seriesId)); + Assert.Equal((1, 1), _service.GetPlayedAndTotalCount(filter, seriesId)); + Assert.Equal((1, 1), _service.GetPlayedAndTotalCountBatch([seriesId], user)[seriesId]); + } + + [Fact] + public void GetCounts_MultiVersionMovie_CountPlaybackOfAnyVersion() + { + // Two movies held as two files each: the primary the collection links, and an alternate version + // linked to it. One movie was watched on its alternate, which is where playback of a second cut + // lands; the other was not watched at all. + var user = new User("alt-version-test", "provider", "reset"); + var boxSetId = Guid.NewGuid(); + var libraryId = Guid.NewGuid(); + var watchedPrimaryId = Guid.NewGuid(); + var watchedAlternateId = Guid.NewGuid(); + var unwatchedPrimaryId = Guid.NewGuid(); + var unwatchedAlternateId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + var boxSet = CreateItem(boxSetId); + boxSet.PresentationUniqueKey = "alt-version-box-set"; + context.BaseItems.Add(boxSet); + + var library = CreateItem(libraryId); + library.PresentationUniqueKey = "alt-version-library"; + context.BaseItems.Add(library); + + foreach (var (primaryId, alternateId) in + new[] { (watchedPrimaryId, watchedAlternateId), (unwatchedPrimaryId, unwatchedAlternateId) }) + { + context.BaseItems.Add(CreateLeaf(primaryId)); + + var alternate = CreateLeaf(alternateId); + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + } + + context.SaveChanges(); + + context.LinkedChildren.AddRange( + new LinkedChildEntity + { + ParentId = boxSetId, + ChildId = watchedPrimaryId, + ChildType = LinkedChildType.Manual, + SortOrder = 0 + }, + new LinkedChildEntity + { + ParentId = boxSetId, + ChildId = unwatchedPrimaryId, + ChildType = LinkedChildType.Manual, + SortOrder = 1 + }, + new LinkedChildEntity + { + ParentId = watchedPrimaryId, + ChildId = watchedAlternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }, + new LinkedChildEntity + { + ParentId = unwatchedPrimaryId, + ChildId = unwatchedAlternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + AddAncestor(context, watchedPrimaryId, libraryId); + AddAncestor(context, unwatchedPrimaryId, libraryId); + + context.UserData.Add(new UserData + { + ItemId = watchedAlternateId, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + // A version group is one item to count, and the alternate's playback makes that item played - + // as it already does for the played flag the primary itself reports. + Assert.Equal((1, 2), _service.GetPlayedAndTotalCountFromLinkedChildren(filter, boxSetId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCountBatch([boxSetId], user)[boxSetId]); + + // The ancestor-based paths answer the same for the library the primaries sit in. + Assert.Equal(1, _service.GetPlayedCount(filter, libraryId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, libraryId)); + } + + [Fact] + public void GetChildCountBatch_NoUser_StillCollapsesAlternateVersions() + { + // Both files of a merged movie sit in the folder. With a user it is access filtering that + // drops the alternate; with no user nothing else would, and the folder would report two + // children for the one title a viewer sees. + var folderId = Guid.NewGuid(); + var primaryId = Guid.NewGuid(); + var alternateId = Guid.NewGuid(); + var extraId = Guid.NewGuid(); + var ownedId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.Add(CreateItem(folderId)); + + var primary = CreateLeaf(primaryId); + primary.ParentId = folderId; + context.BaseItems.Add(primary); + + var alternate = CreateLeaf(alternateId); + alternate.ParentId = folderId; + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + + // An extra carries an owner and an extra type, and stays a child of its own. + var extra = CreateLeaf(extraId); + extra.ParentId = folderId; + extra.OwnerId = primaryId; + extra.ExtraType = BaseItemExtraType.Trailer; + context.BaseItems.Add(extra); + + // An owned item that is not an extra belongs to its owner, not to the folder. + var owned = CreateLeaf(ownedId); + owned.ParentId = folderId; + owned.OwnerId = primaryId; + context.BaseItems.Add(owned); + + context.SaveChanges(); + } + + Assert.Equal(2, _service.GetChildCountBatch([folderId], null)[folderId]); + } + + [Fact] public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys() { var seriesA = Guid.NewGuid(); @@ -335,6 +565,695 @@ public sealed class ItemCountServiceTests : IDisposable }; } + [Fact] + public void GetItemCountsForNameItems_MatchesCountingEachNameItemOnItsOwn() + { + // Three genres tagging a different number of movies each, plus one tagging nothing. + var genres = SeedGenres(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie, BaseItemKind.Series]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); + + // Every requested id is answered, so a caller can index the result without checking. + Assert.Equal(genres.Count, batch.Count); + + foreach (var genreId in genres) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + + // And the counts are the seeded ones rather than all zero, which would match trivially. + Assert.Equal([3, 2, 1, 0], genres.Select(g => batch[g].MovieCount).ToArray()); + } + + [Fact] + public void GetItemCountsForNameItems_UnknownId_CountsZero() + { + var unknown = Guid.NewGuid(); + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.Genre, + [unknown], + [BaseItemKind.Movie], + new InternalItemsQuery()); + + Assert.Equal(0, batch[unknown].ItemCount); + } + + [Fact] + public void GetItemCountsForNameItems_ArtistTaggedTwiceOnOneAlbum_CountsTheAlbumOnce() + { + // An album whose artist is also its album artist maps to the same artist twice. + var artistId = SeedArtistWithAlbum(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.MusicAlbum]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.MusicArtist, [artistId], related, filter); + var single = _service.GetItemCountsForNameItem(BaseItemKind.MusicArtist, artistId, related, filter); + + Assert.Equal(1, batch[artistId].AlbumCount); + Assert.Equal(single.AlbumCount, batch[artistId].AlbumCount); + Assert.Equal(single.ItemCount, batch[artistId].ItemCount); + } + + /// <summary> + /// Seeds one artist and a single album tagged with it as both artist and album artist. + /// </summary> + /// <returns>The id of the seeded artist.</returns> + private Guid SeedArtistWithAlbum() + { + const string Name = "artist-0"; + var artistId = Guid.NewGuid(); + var albumId = Guid.NewGuid(); + + using var context = CreateDbContext(); + + var artist = CreateItem(artistId); + artist.Type = "MusicArtist"; + artist.Name = Name; + artist.CleanName = Name; + context.BaseItems.Add(artist); + + var album = CreateItem(albumId); + album.Type = "MusicAlbum"; + context.BaseItems.Add(album); + context.SaveChanges(); + + foreach (var type in new[] { ItemValueType.Artist, ItemValueType.AlbumArtist }) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = Name, + CleanValue = Name + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = albumId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + + context.SaveChanges(); + + return artistId; + } + + [Fact] + public void GetItemCountsForNameItems_LargeIdSet_DoesNotExceedSqliteVariableLimit() + { + // Seeded rather than random, so the clean names of every one of them reach the second + // query's IN list and the join behind it, instead of stopping at the empty-name return. + var seeded = SeedArtists(50, out var taggedArtistId); + + var ids = seeded.Concat(Enumerable.Range(0, 40_000).Select(_ => Guid.NewGuid())).ToList(); + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, + ids, + [BaseItemKind.MusicAlbum], + new InternalItemsQuery()); + + Assert.Equal(ids.Count, batch.Count); + + // And the grouped query really ran, rather than every id coming back zeroed. + Assert.Equal(1, batch[taggedArtistId].AlbumCount); + } + + [Fact] + public void GetItemCountsForNameItems_QueryShape_DoesNotVaryWithBatchSize() + { + // Every id list has to be bound as one parameter rather than one placeholder each: that is + // what keeps the statement off the SQLite variable ceiling and out of a per-size entry in + // EF's compiled query cache. Identical SQL for two batch sizes is exactly that property. + var seeded = SeedArtists(6, out _); + + var small = CaptureSql(() => _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, seeded.Take(2).ToList(), [BaseItemKind.MusicAlbum], new InternalItemsQuery())); + + var large = CaptureSql(() => _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, seeded, [BaseItemKind.MusicAlbum], new InternalItemsQuery())); + + Assert.NotEmpty(small); + Assert.Equal(small, large); + } + + private List<string> CaptureSql(Action action) + { + _capturedSql = []; + try + { + action(); + return _capturedSql; + } + finally + { + _capturedSql = null; + } + } + + /// <summary> + /// Seeds the requested number of artists, each with a clean name of its own, one of which is + /// credited on a single album. + /// </summary> + /// <param name="count">The number of artists to seed.</param> + /// <param name="taggedArtistId">The id of the artist credited on an album.</param> + /// <returns>The ids of the seeded artists.</returns> + private List<Guid> SeedArtists(int count, out Guid taggedArtistId) + { + var ids = new List<Guid>(count); + using var context = CreateDbContext(); + + ItemValue? taggedValue = null; + taggedArtistId = Guid.Empty; + + for (var i = 0; i < count; i++) + { + var name = "bulk-artist-" + i.ToString(CultureInfo.InvariantCulture); + var artistId = Guid.NewGuid(); + ids.Add(artistId); + + var artist = CreateItem(artistId); + artist.Type = "MusicArtist"; + artist.Name = name; + artist.CleanName = name; + context.BaseItems.Add(artist); + + if (i == 0) + { + taggedArtistId = artistId; + taggedValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Artist, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(taggedValue); + } + } + + context.SaveChanges(); + + var albumId = Guid.NewGuid(); + var album = CreateItem(albumId); + album.Type = "MusicAlbum"; + context.BaseItems.Add(album); + context.SaveChanges(); + + Tag(context, albumId, taggedValue!.ItemValueId); + context.SaveChanges(); + + return ids; + } + + [Fact] + public void GetItemCountsForNameItems_KindWithoutItemValues_FallsBackToTheSingleItemPath() + { + // Year is keyed by ProductionYear rather than a cleaned item value, so it cannot be grouped. + var yearId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + var year = CreateItem(yearId); + year.Type = "Year"; + year.Name = "2001"; + year.CleanName = "2001"; + context.BaseItems.Add(year); + + for (var i = 0; i < 2; i++) + { + var movie = CreateItem(Guid.NewGuid()); + movie.Type = "Movie"; + movie.IsFolder = false; + movie.ProductionYear = 2001; + context.BaseItems.Add(movie); + } + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Year, [yearId], related, filter); + var single = _service.GetItemCountsForNameItem(BaseItemKind.Year, yearId, related, filter); + + Assert.Equal(2, batch[yearId].MovieCount); + Assert.Equal(single.MovieCount, batch[yearId].MovieCount); + } + + [Fact] + public void GetItemCountsForNameItems_PeopleAndYears_AreBatchedToo() + { + var (personIds, yearIds) = SeedPeopleAndYears(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + foreach (var (kind, ids) in new[] { (BaseItemKind.Person, personIds), (BaseItemKind.Year, yearIds) }) + { + var contextsBefore = _contextsCreated; + var batch = _service.GetItemCountsForNameItems(kind, ids, related, filter); + + // These two used to be answered one query per id; only the value keyed kinds batched. + Assert.Equal(1, _contextsCreated - contextsBefore); + + Assert.Equal(ids.Count, batch.Count); + Assert.Equal(2, batch[ids[0]].MovieCount); + Assert.Equal(1, batch[ids[1]].MovieCount); + + foreach (var id in ids) + { + var single = _service.GetItemCountsForNameItem(kind, id, related, filter); + Assert.Equal(single.MovieCount, batch[id].MovieCount); + Assert.Equal(single.ItemCount, batch[id].ItemCount); + } + } + } + + /// <summary> + /// Seeds two people and two years, the first of each on two movies and the second on one. + /// </summary> + /// <returns>The ids of the seeded people and years.</returns> + private (List<Guid> PersonIds, List<Guid> YearIds) SeedPeopleAndYears() + { + var personIds = new List<Guid>(); + var yearIds = new List<Guid>(); + + using var context = CreateDbContext(); + + for (var i = 0; i < 2; i++) + { + var personName = "person-" + i.ToString(CultureInfo.InvariantCulture); + var personId = Guid.NewGuid(); + personIds.Add(personId); + + var person = CreateItem(personId); + person.Type = "Person"; + person.Name = personName; + person.CleanName = personName; + context.BaseItems.Add(person); + + var people = new People { Id = Guid.NewGuid(), Name = personName }; + context.Peoples.Add(people); + + var year = 2000 + i; + var yearId = Guid.NewGuid(); + yearIds.Add(yearId); + + var yearItem = CreateItem(yearId); + yearItem.Type = "Year"; + yearItem.Name = year.ToString(CultureInfo.InvariantCulture); + yearItem.CleanName = yearItem.Name; + context.BaseItems.Add(yearItem); + context.SaveChanges(); + + // Two movies for the first of each, one for the second. + for (var m = 0; m < 2 - i; m++) + { + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + movie.ProductionYear = year; + context.BaseItems.Add(movie); + context.SaveChanges(); + + context.PeopleBaseItemMap.Add(new PeopleBaseItemMap + { + ItemId = movieId, + PeopleId = people.Id, + Item = null!, + People = null!, + Role = "Actor", + ListOrder = m, + SortOrder = m + }); + } + + context.SaveChanges(); + } + + return (personIds, yearIds); + } + + [Theory] + // The set the by-name listing actually asks for: it rolls the episodes of a tagged series up + // into the genre, which is the case the batch has to reproduce query for query. + [InlineData(BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie)] + // And the same seeded data without the roll-up, which takes the plain grouped path. + [InlineData(BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicAlbum)] + public void GetItemCountsForNameItems_TaggedSeriesAndEpisodes_MatchesCountingEachNameItemOnItsOwn( + BaseItemKind first, + BaseItemKind second, + BaseItemKind third) + { + var genres = SeedGenresTaggingSeriesAndEpisodes(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [first, second, third]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); + + Assert.Equal(genres.Count, batch.Count); + + foreach (var genreId in genres) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + + Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); + Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + } + + [Fact] + public void GetItemCountsForNameItems_TaggedSeries_RollsEpisodesUpIntoTheGenre() + { + var genres = SeedGenresTaggingSeriesAndEpisodes(); + + var contextsBefore = _contextsCreated; + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.Genre, + genres, + [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie], + new InternalItemsQuery()); + + // The whole point of the batch: one context for every genre on the page, not one each. + // The roll-up used to force this shape back onto the single item path. + Assert.Equal(1, _contextsCreated - contextsBefore); + + // "rolled": one tagged series of two episodes, one of which carries the genre itself, plus + // a loose tagged episode of an untagged series. The tagged episode of the tagged series + // must not be counted twice. + Assert.Equal(3, batch[genres[0]].EpisodeCount); + Assert.Equal(1, batch[genres[0]].SeriesCount); + + // "loose": a tagged episode whose series carries no genre at all. + Assert.Equal(1, batch[genres[1]].EpisodeCount); + Assert.Equal(0, batch[genres[1]].SeriesCount); + + // "empty": tags nothing. + Assert.Equal(0, batch[genres[2]].EpisodeCount); + } + + [Fact] + public void GetItemCountsForNameItems_EpisodeAndItsSeriesTaggedDifferently_KeepsTheGenresApart() + { + var seriesId = Guid.NewGuid(); + var episodeId = Guid.NewGuid(); + var genreIds = new List<Guid>(); + + using (var context = CreateDbContext()) + { + var values = new Dictionary<string, Guid>(StringComparer.Ordinal); + foreach (var name in new[] { "on-series", "on-episode" }) + { + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + values[name] = itemValue.ItemValueId; + } + + var series = CreateItem(seriesId); + series.Type = "Series"; + context.BaseItems.Add(series); + context.BaseItems.Add(CreateEpisode(episodeId, seriesId)); + context.SaveChanges(); + + Tag(context, seriesId, values["on-series"]); + Tag(context, episodeId, values["on-episode"]); + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genreIds, related, filter); + + // The episode rolls up into the genre on its series. + Assert.Equal(1, batch[genreIds[0]].EpisodeCount); + + // Its own genre is carried by no series, so the episode stays a direct count there. Keyed + // on the series id alone the episode would be subtracted here and this would read 0. + Assert.Equal(1, batch[genreIds[1]].EpisodeCount); + Assert.Equal(0, batch[genreIds[1]].SeriesCount); + + foreach (var genreId in genreIds) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + } + + [Fact] + public void GetItemCountsForNameItems_TwoNameItemsSharingACleanName_BothGetTheCounts() + { + // Distinct rows cleaning down to one name are what the batch keys on; the unique index + // permits them, so two genre items can legitimately share a clean name. + var firstId = Guid.NewGuid(); + var secondId = Guid.NewGuid(); + var movieId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + foreach (var (id, name) in new[] { (firstId, "Sci-Fi"), (secondId, "SCI-FI") }) + { + var genre = CreateItem(id); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = "sci-fi"; + context.BaseItems.Add(genre); + } + + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + context.SaveChanges(); + + foreach (var name in new[] { "Sci-Fi", "SCI-FI" }) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = "sci-fi" + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + Tag(context, movieId, itemValue.ItemValueId); + } + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, [firstId, secondId], related, filter); + + // One movie, reached through two value rows: counted once for each genre item, not twice. + Assert.Equal(1, batch[firstId].MovieCount); + Assert.Equal(1, batch[secondId].MovieCount); + + foreach (var genreId in new[] { firstId, secondId }) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + } + } + + /// <summary> + /// Seeds three genres: one tagging a series whose episodes roll up (one of them tagged too) + /// plus a loose episode, one tagging only an episode of an untagged series, and one tagging + /// nothing. + /// </summary> + /// <returns>The ids of the seeded genres, in that order.</returns> + private List<Guid> SeedGenresTaggingSeriesAndEpisodes() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + var values = new Dictionary<string, Guid>(StringComparer.Ordinal); + foreach (var name in new[] { "rolled", "loose", "empty" }) + { + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + values[name] = itemValue.ItemValueId; + } + + context.SaveChanges(); + + // A series tagged "rolled" holding two episodes; the second carries "rolled" itself, so the + // roll-up and the direct tag both see it. + var taggedSeriesId = Guid.NewGuid(); + var taggedSeries = CreateItem(taggedSeriesId); + taggedSeries.Type = "Series"; + context.BaseItems.Add(taggedSeries); + + var episodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); + var taggedEpisodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); + context.BaseItems.AddRange(episodeOfTaggedSeries, taggedEpisodeOfTaggedSeries); + + // An untagged series whose episode carries a genre on its own. + var untaggedSeriesId = Guid.NewGuid(); + var untaggedSeries = CreateItem(untaggedSeriesId); + untaggedSeries.Type = "Series"; + context.BaseItems.Add(untaggedSeries); + + var looseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); + var rolledLooseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); + context.BaseItems.AddRange(looseEpisode, rolledLooseEpisode); + + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + + context.SaveChanges(); + + Tag(context, taggedSeriesId, values["rolled"]); + Tag(context, taggedEpisodeOfTaggedSeries.Id, values["rolled"]); + Tag(context, rolledLooseEpisode.Id, values["rolled"]); + Tag(context, looseEpisode.Id, values["loose"]); + Tag(context, movieId, values["rolled"]); + + context.SaveChanges(); + + return genreIds; + } + + private static void Tag(JellyfinDbContext context, Guid itemId, Guid itemValueId) + { + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = itemId, + ItemValueId = itemValueId, + Item = null!, + ItemValue = null! + }); + } + + private static BaseItemEntity CreateEpisode(Guid id, Guid seriesId) + { + return new BaseItemEntity + { + Id = id, + Type = "Episode", + IsFolder = false, + IsVirtualItem = false, + ParentId = seriesId, + SeriesId = seriesId + }; + } + + /// <summary> + /// Seeds four genres tagging three, two, one and no movies, in that order. + /// </summary> + /// <returns>The ids of the seeded genres.</returns> + private List<Guid> SeedGenres() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + for (var i = 0; i < 4; i++) + { + var name = "genre-" + i.ToString(CultureInfo.InvariantCulture); + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + + // 3 movies for the first genre, 2 for the second, 1 for the third, none for the last. + for (var m = 0; m < 3 - i; m++) + { + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + context.SaveChanges(); + + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = movieId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + + context.SaveChanges(); + } + + return genreIds; + } + private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null) { return new BaseItemEntity diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs new file mode 100644 index 0000000000..c15ea09965 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the invariant that a video linked as an alternate version also carries the +/// PrimaryVersionId the item queries hide it by, including when it was already a library +/// item in its own right before it became a version. +/// </summary> +public sealed class ItemPersistenceAlternateVersionTests : SqliteDbTestFixture +{ + private const string PrimaryPath = "/movies/Movie/Movie - 4K.mkv"; + private const string VersionPath = "/movies/Movie/Movie - 1080p.mkv"; + + private readonly ItemPersistenceService _service; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IServerConfigurationManager? _previousConfigurationManager; + private readonly IRecordingsManager? _previousRecordingsManager; + + public ItemPersistenceAlternateVersionTests() + { + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousConfigurationManager = BaseItem.ConfigurationManager; + _previousRecordingsManager = Video.RecordingsManager; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>())) + .Returns([]); + BaseItem.LibraryManager = libraryManager.Object; + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + BaseItem.ConfigurationManager = configurationManager.Object; + + // Video.SourceType asks this whether the file is an in-progress recording. + Video.RecordingsManager = new Mock<IRecordingsManager>().Object; + + // Paths round-trip through the host's virtual path mapping on the way in and out. + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(h => h.ReverseVirtualPath(It.IsAny<string>())).Returns((string p) => p); + appHost.Setup(h => h.ExpandVirtualPath(It.IsAny<string>())).Returns((string p) => p); + + _service = new ItemPersistenceService( + CreateDbContextFactory(), + appHost.Object, + NullLogger<ItemPersistenceService>.Instance); + } + + protected override void Dispose(bool disposing) + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.ConfigurationManager = _previousConfigurationManager!; + Video.RecordingsManager = _previousRecordingsManager!; + base.Dispose(disposing); + } + + [Fact] + public void SaveItems_LocalAlternateVersionAlreadyAnItem_SetsPrimaryVersionId() + { + var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + // The version was scanned as a standalone movie before it became a version, so it has a + // presentation key of its own and no PrimaryVersionId. + var version = CreateMovie(versionId, VersionPath); + version.PresentationUniqueKey = "standalone"; + _service.SaveItems([version], CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId); + } + + // Now the scan folds it into a primary, which is the item that gets saved. + var primary = CreateMovie(primaryId, PrimaryPath); + primary.LocalAlternateVersions = [VersionPath]; + _service.SaveItems([primary], CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId))); + Assert.Equal(DbLinkedChildType.LocalAlternateVersion, link.ChildType); + Assert.Equal(versionId, link.ChildId); + + var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId)); + Assert.Equal(primaryId, stored.PrimaryVersionId); + + // Presentation-key grouping has to collapse it onto the primary as well. + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey); + } + } + + [Fact] + public void SaveItems_LinkedAlternateVersionAlreadyAnItem_SetsPrimaryVersionId() + { + var primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + var versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + _service.SaveItems([CreateMovie(versionId, VersionPath)], CancellationToken.None); + + var primary = CreateMovie(primaryId, PrimaryPath); + primary.LinkedAlternateVersions = + [ + new LinkedChild { ItemId = versionId, Type = LinkedChildType.LinkedAlternateVersion } + ]; + _service.SaveItems([primary], CancellationToken.None); + + using var ctx = CreateDbContext(); + var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId))); + Assert.Equal(DbLinkedChildType.LinkedAlternateVersion, link.ChildType); + Assert.Equal(primaryId, ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId); + } + + [Fact] + public void SaveItems_VersionAlreadyPointingAtPrimary_LeavesItAlone() + { + var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + var version = CreateMovie(versionId, VersionPath); + version.SetPrimaryVersionId(primaryId); + _service.SaveItems([version], CancellationToken.None); + + var primary = CreateMovie(primaryId, PrimaryPath); + primary.LocalAlternateVersions = [VersionPath]; + _service.SaveItems([primary], CancellationToken.None); + + using var ctx = CreateDbContext(); + var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId)); + Assert.Equal(primaryId, stored.PrimaryVersionId); + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey); + } + + [Fact] + public void SaveItems_VideoListedAmongItsOwnVersions_KeepsItsOwnPrimaryVersionId() + { + var primaryId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + var primary = CreateMovie(primaryId, PrimaryPath); + primary.LocalAlternateVersions = [PrimaryPath]; + _service.SaveItems([primary], CancellationToken.None); + + using var ctx = CreateDbContext(); + Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(primaryId)).PrimaryVersionId); + } + + [Fact] + public void SaveItems_PromotedVersionStillPointingAtOldPrimary_DoesNotCreateACycle() + { + var promotedId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var oldPrimaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + _service.SaveItems([CreateMovie(oldPrimaryId, VersionPath)], CancellationToken.None); + + // The rescan resolves this one as the primary of the group, but it still carries the pointer + // to the version it was promoted over. + var promoted = CreateMovie(promotedId, PrimaryPath); + promoted.SetPrimaryVersionId(oldPrimaryId); + promoted.LocalAlternateVersions = [VersionPath]; + _service.SaveItems([promoted], CancellationToken.None); + + using var ctx = CreateDbContext(); + + // Pointing the old primary back would hide both, and with them the whole group. + Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(oldPrimaryId)).PrimaryVersionId); + Assert.Equal(oldPrimaryId, ctx.BaseItems.First(e => e.Id.Equals(promotedId)).PrimaryVersionId); + } + + private static Movie CreateMovie(Guid id, string path) => new() + { + Id = id, + Name = "Movie", + Path = path + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs new file mode 100644 index 0000000000..e2bdd9e0b2 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// DeleteItem has to hand SQLite one statement that already contains everything the foreign keys +/// on BaseItems require, because FK_BaseItems_BaseItems_OwnerId is NO ACTION: anything left behind +/// pointing at a deleted row fails the whole delete with SQLite error 19. +/// </summary> +public sealed class ItemPersistenceDeleteItemTests : SqliteDbTestFixture +{ + private static readonly Guid _owner = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + private static readonly Guid _extra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001"); + private static readonly Guid _extraOfExtra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000002"); + private static readonly Guid _child = Guid.Parse("cccccccc-0000-0000-0000-000000000001"); + private static readonly Guid _extraOfChild = Guid.Parse("eeeeeeee-0000-0000-0000-000000000003"); + + private readonly ItemPersistenceService _service; + + public ItemPersistenceDeleteItemTests() + { + _service = new ItemPersistenceService( + CreateDbContextFactory(), + new Mock<IServerApplicationHost>().Object, + NullLogger<ItemPersistenceService>.Instance); + } + + [Fact] + public void DeleteItem_OwnerIdChain_DeletesWholeChain() + { + // An extra that owns an extra of its own. Real libraries carry these in bulk, and a single + // expansion pass over OwnerId leaves the second level behind. + Seed( + (_owner, null, null), + (_extra, _owner, null), + (_extraOfExtra, _extra, null)); + + _service.DeleteItem([_owner]); + + using var context = CreateDbContext(); + Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra) || e.Id.Equals(_extraOfExtra))); + } + + [Fact] + public void DeleteItem_ExtraOwnedByCascadedChild_DeletesExtraToo() + { + // The child goes away through FK_BaseItems_BaseItems_ParentId's ON DELETE CASCADE whether or + // not it is listed, so an extra owned by that child has to be listed with it. + Seed( + (_owner, null, null), + (_child, null, _owner), + (_extraOfChild, _child, null)); + + _service.DeleteItem([_owner]); + + using var context = CreateDbContext(); + Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_child) || e.Id.Equals(_extraOfChild))); + } + + [Fact] + public void DeleteItem_OwnershipCycle_Terminates() + { + // A malformed pair that owns each other must not spin the closure loop forever. + Seed((_owner, null, null), (_extra, _owner, null)); + + using (var context = CreateDbContext()) + { + context.BaseItems.Single(e => e.Id.Equals(_owner)).OwnerId = _extra; + context.SaveChanges(); + } + + _service.DeleteItem([_owner]); + + using var assertContext = CreateDbContext(); + Assert.Empty(assertContext.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra))); + } + + private void Seed(params (Guid Id, Guid? OwnerId, Guid? ParentId)[] items) + { + using var context = CreateDbContext(); + + // Owners before the rows referencing them: the seed itself is foreign key checked. + foreach (var (id, ownerId, parentId) in items) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = "MediaBrowser.Controller.Entities.Video", + OwnerId = ownerId, + ParentId = parentId + }); + + context.SaveChanges(); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs new file mode 100644 index 0000000000..fc28025573 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ItemPersistencePeopleCleanupTests : SqliteDbTestFixture +{ + private readonly ItemPersistenceService _service; + + public ItemPersistencePeopleCleanupTests() + { + _service = new ItemPersistenceService( + CreateDbContextFactory(), + Mock.Of<IServerApplicationHost>(), + NullLogger<ItemPersistenceService>.Instance); + } + + [Fact] + public void DeleteItem_RemovesUnusedPeopleForItemsDescendantsAndExtras() + { + var parent = CreateItem(isFolder: true); + var child = CreateItem(); + child.ParentId = parent.Id; + var extra = CreateItem(); + extra.OwnerId = child.Id; + var survivor = CreateItem(); + var shared = CreatePerson("Shared person"); + var unrelatedOrphan = CreatePerson("Unrelated orphan"); + using (var context = CreateDbContext()) + { + context.PeopleBaseItemMap.AddRange( + Map(parent, CreatePerson("Parent credit")), + Map(child, CreatePerson("Child credit")), + Map(extra, CreatePerson("Extra credit")), + Map(child, shared), + Map(survivor, shared)); + context.Peoples.Add(unrelatedOrphan); + context.AncestorIds.Add(new AncestorId + { + ItemId = child.Id, + Item = child, + ParentItemId = parent.Id, + ParentItem = parent + }); + context.SaveChanges(); + } + + _service.DeleteItem([parent.Id]); + + using var after = CreateDbContext(); + Assert.Equal(survivor.Id, Assert.Single(after.BaseItems.Where(e => !e.Id.Equals(BaseItemRepository.PlaceholderId))).Id); + Assert.Equal(survivor.Id, Assert.Single(after.PeopleBaseItemMap).ItemId); + Assert.Equal(new[] { shared.Id, unrelatedOrphan.Id }.Order(), after.Peoples.Select(e => e.Id).Order()); + } + + private static BaseItemEntity CreateItem(bool isFolder = false) => new() + { + Id = Guid.NewGuid(), + Type = isFolder ? typeof(Folder).FullName! : typeof(Book).FullName!, + IsFolder = isFolder + }; + + private static People CreatePerson(string name) => new() + { + Id = Guid.NewGuid(), + Name = name, + PersonType = "Actor" + }; + + private static PeopleBaseItemMap Map(BaseItemEntity item, People person) => new() + { + ItemId = item.Id, + Item = item, + PeopleId = person.Id, + People = person, + Role = string.Empty + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs new file mode 100644 index 0000000000..7997c6d771 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture +{ + private readonly ItemPersistenceService _service; + + public ItemPersistenceServiceSaveImagesTests() + { + _service = new ItemPersistenceService( + CreateDbContextFactory(), + Mock.Of<IServerApplicationHost>(), + NullLogger<ItemPersistenceService>.Instance); + } + + [Fact] + public async Task SaveImagesAsync_ReplacesThePreviousImages() + { + var itemId = Guid.NewGuid(); + Seed(itemId); + + await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken); + await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken); + + using var context = CreateDbContext(); + var paths = context.BaseItemImageInfos + .Where(e => e.ItemId.Equals(itemId)) + .Select(e => e.Path) + .ToList(); + + Assert.Equal(["/second.jpg"], paths); + } + + [Fact] + public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp() + { + // A scan can delete the item between the refresh reading it and the images being written. That + // must not fail the whole refresh, and must not leave the images of an item that is gone. + var itemId = Guid.NewGuid(); + + await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken); + + using var context = CreateDbContext(); + Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId))); + } + + private static BaseItem CreateItem(Guid itemId, string imagePath) + => new Folder + { + Id = itemId, + ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }] + }; + + private void Seed(Guid itemId) + { + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = itemId, + Type = "Folder", + IsFolder = true + }); + context.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs new file mode 100644 index 0000000000..8ed3c61a59 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers Next Up over episodes with alternate versions: the episode that was watched is the one +/// whose alternate carries the played row, so an episode already seen must not be offered again. +/// </summary> +public sealed class NextUpServiceTests : SqliteDbTestFixture +{ + private const string SeriesKey = "next-up-series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + + private readonly NextUpService _service; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + private readonly Guid _playedViaAlternate = Guid.NewGuid(); + private readonly Guid _unplayed = Guid.NewGuid(); + + public NextUpServiceTests() + { + var itemTypeLookup = new ItemTypeLookup(); + + using (var context = CreateDbContext()) + { + Seed(context); + } + + _service = new NextUpService( + CreateDbContextFactory(), + itemTypeLookup, + CreateBaseItemRepository(itemTypeLookup)); + } + + [Fact] + public void GetNextUpEpisodesBatch_EpisodePlayedThroughItsAlternateVersion_OffersTheOneAfterIt() + { + var batch = _service.GetNextUpEpisodesBatch( + new InternalItemsQuery(_user), + [SeriesKey], + includeSpecials: false, + includeWatchedForRewatching: false)[SeriesKey]; + + Assert.Equal(_playedViaAlternate, batch.LastWatched?.Id); + Assert.Equal(_unplayed, batch.NextUp?.Id); + } + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + AddEpisode(context, _playedViaAlternate, 1); + AddEpisode(context, _unplayed, 2); + + // The second file of the first episode, and the only row the playback was recorded against. + // It presents under its primary's key, which is what keeps it out of the candidate list. + var alternateId = Guid.NewGuid(); + context.BaseItems.Add(new BaseItemEntity + { + Id = alternateId, + Type = EpisodeType, + Name = "Episode 1 4K", + SeriesPresentationUniqueKey = SeriesKey, + ParentIndexNumber = 1, + IndexNumber = 1, + PresentationUniqueKey = _playedViaAlternate.ToString("N"), + PrimaryVersionId = _playedViaAlternate + }); + + context.SaveChanges(); + + // The link the scanner writes alongside PrimaryVersionId, and the hop the played state + // reaches the alternate through. + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _playedViaAlternate, + ChildId = alternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + context.UserData.Add(new UserData + { + ItemId = alternateId, + UserId = _user.Id, + CustomDataKey = alternateId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); + + context.SaveChanges(); + } + + private void AddEpisode(JellyfinDbContext context, Guid id, int indexNumber) + => context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = EpisodeType, + Name = $"Episode {indexNumber}", + SeriesPresentationUniqueKey = SeriesKey, + ParentIndexNumber = 1, + IndexNumber = indexNumber, + PresentationUniqueKey = id.ToString("N") + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs new file mode 100644 index 0000000000..b925f98197 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class PeopleUpdateQueryTests : SqliteDbTestFixture +{ + private readonly CommandRecorder _recorder; + private readonly Guid _itemId = Guid.NewGuid(); + private readonly PeopleRepository _people; + + public PeopleUpdateQueryTests() + : this(new CommandRecorder()) + { + } + + private PeopleUpdateQueryTests(CommandRecorder recorder) + : base(recorder) + { + _recorder = recorder; + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = _itemId, + Name = "Movie", + Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie] + }); + context.SaveChanges(); + _people = new PeopleRepository(CreateDbContextFactory(), new ItemTypeLookup(), Mock.Of<IItemQueryHelpers>()); + } + + [Theory] + [InlineData("Hero")] + [InlineData("HERO")] + public void UnchangedCredits_DoNotWriteOrLookUpAllPeople(string role) + { + _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, Role = "Hero" }]); + _recorder.Commands.Clear(); + _people.UpdatePeople(_itemId, [new PersonInfo { Name = "actor", Type = PersonKind.Actor, Role = role }]); + Assert.Single(_recorder.Commands); + Assert.StartsWith("SELECT", _recorder.Commands[0].Sql, StringComparison.Ordinal); + using var context = CreateDbContext(); + Assert.Equal("Hero", Assert.Single(context.PeopleBaseItemMap).Role); + } + + [Fact] + public void SortOrderChange_IsPersisted() + { + _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 1 }]); + _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 2 }]); + using var context = CreateDbContext(); + Assert.Equal(2, Assert.Single(context.PeopleBaseItemMap).SortOrder); + } + + [Fact] + public void UpdatePeople_GeneratedSqlUsesPeopleNameIndex() + { + ApplyMigration(new Jellyfin.Server.Implementations.Migrations.AddPeopleNameLowerIndex()); + _recorder.Commands.Clear(); + _people.UpdatePeople(_itemId, [ + new PersonInfo { Name = "Actor A", Type = PersonKind.Actor }, + new PersonInfo { Name = "Actor B", Type = PersonKind.Actor } + ]); + var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("lower(\"p\".\"Name\")", StringComparison.Ordinal)); + Assert.Contains(Explain(query), line => line.Contains("SEARCH p USING INDEX IX_Peoples_NameLower", StringComparison.Ordinal)); + } + + private void ApplyMigration(Migration migration) + { + using var context = CreateDbContext(); + foreach (var operation in migration.UpOperations.Cast<SqlOperation>()) + { + context.Database.ExecuteSqlRaw(operation.Sql); + } + } + + 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/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs index 87efa8fea5..6da176b4f1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Locking; @@ -10,6 +11,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Configuration; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -25,7 +27,7 @@ public abstract class SqliteDbTestFixture : IDisposable private readonly SqliteConnection _connection; private readonly DbContextOptions<JellyfinDbContext> _dbOptions; - protected SqliteDbTestFixture() + protected SqliteDbTestFixture(params IInterceptor[] interceptors) { ApplicationPaths = new Mock<IApplicationPaths>().Object; @@ -34,6 +36,7 @@ public abstract class SqliteDbTestFixture : IDisposable _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() .UseSqlite(_connection) + .AddInterceptors(interceptors) .Options; using var context = CreateDbContext(); @@ -58,6 +61,8 @@ public abstract class SqliteDbTestFixture : IDisposable { var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); return factory.Object; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs new file mode 100644 index 0000000000..31109b2968 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager; + +public sealed class ResolveAlternateVersionTests : IDisposable +{ + private const string PrimaryPath = "/movies/Up/Up.mkv"; + private const string AlternatePath = "/movies/Up/Up - 1080p.mkv"; + + private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; + private readonly Mock<IItemPersistenceService> _persistenceServiceMock; + private readonly Folder _staleParent; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IMediaSourceManager? _previousMediaSourceManager; + private readonly IItemRepository? _previousItemRepository; + + public ResolveAlternateVersionTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + fixture.Freeze<Mock<IServerConfigurationManager>>() + .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + _persistenceServiceMock = fixture.Freeze<Mock<IItemPersistenceService>>(); + var itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>(); + fixture.Freeze<Mock<IFileSystem>>() + .Setup(f => f.GetFileInfo(It.IsAny<string>())) + .Returns<string>(path => new FileSystemMetadata { FullName = path }); + + _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>() + .Do(s => s.AddParts( + fixture.Create<IEnumerable<IResolverIgnoreRule>>(), + [], + fixture.Create<IEnumerable<IIntroProvider>>(), + fixture.Create<IEnumerable<IBaseItemComparer>>(), + fixture.Create<IEnumerable<ILibraryPostScanTask>>())) + .Create(); + + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousMediaSourceManager = BaseItem.MediaSourceManager; + _previousItemRepository = BaseItem.ItemRepository; + BaseItem.LibraryManager = _libraryManager; + + var mediaSourceManagerMock = new Mock<IMediaSourceManager>(); + mediaSourceManagerMock.Setup(m => m.GetMediaStreams(It.IsAny<Guid>())).Returns([]); + mediaSourceManagerMock.Setup(m => m.GetMediaAttachments(It.IsAny<Guid>())).Returns([]); + BaseItem.MediaSourceManager = mediaSourceManagerMock.Object; + + // A reloaded listing comes back empty, so a stale entry surviving is visible. + itemRepositoryMock.Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>())).Returns([]); + BaseItem.ItemRepository = itemRepositoryMock.Object; + + BaseItem.FileSystem ??= fixture.Create<IFileSystem>(); + BaseItem.MediaSegmentManager ??= fixture.Create<IMediaSegmentManager>(); + BaseItem.ConfigurationManager ??= fixture.Create<IServerConfigurationManager>(); + Video.RecordingsManager ??= fixture.Create<IRecordingsManager>(); + + var primary = new Movie + { + Name = "Up", + Path = PrimaryPath, + LocalAlternateVersions = [AlternatePath], + Id = _libraryManager.GetNewItemId(PrimaryPath, typeof(Movie)) + }; + + _staleParent = new Folder + { + Name = "Up", + Path = "/movies/Up", + Id = _libraryManager.GetNewItemId("/movies/Up", typeof(Folder)) + }; + + var staleAlternate = new Video + { + Name = "Up - 1080p", + Path = AlternatePath, + OwnerId = primary.Id, + ParentId = _staleParent.Id, + Id = _libraryManager.GetNewItemId(AlternatePath, typeof(Video)) + }; + staleAlternate.SetPrimaryVersionId(primary.Id); + + itemRepositoryMock + .Setup(i => i.RetrieveItem(It.IsAny<Guid>())) + .Returns<Guid>(id => id.Equals(primary.Id) ? primary + : id.Equals(staleAlternate.Id) ? staleAlternate + : id.Equals(_staleParent.Id) ? _staleParent + : null!); + + StaleAlternateId = staleAlternate.Id; + } + + private Guid StaleAlternateId { get; } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.MediaSourceManager = _previousMediaSourceManager!; + BaseItem.ItemRepository = _previousItemRepository!; + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsRowWithoutResavingPrimary() + { + // The alternate is stored under the id of the generic Video type while its primary is a Movie. + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + _persistenceServiceMock.Verify( + p => p.DeleteItem(It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 1 && ids[0].Equals(StaleAlternateId))), + Times.Once); + + // Saving the primary is what re-enters this method before the stale row is gone. + _persistenceServiceMock.Verify( + p => p.SaveItems(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsCachedParentListing() + { + _staleParent.Children = [new Video { Name = "Up - 1080p", Path = AlternatePath }]; + + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + Assert.Empty(_staleParent.Children); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs new file mode 100644 index 0000000000..6d0c491382 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.ScheduledTasks.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Tasks; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class LibraryManagerScanTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StartScanInBackground_QueuesOnlyWhenIdle(bool scanRunning) + { + 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"); + var tasks = fixture.Freeze<Mock<ITaskManager>>(); + var manager = fixture.Create<ServerLibraryManager>(); + typeof(ServerLibraryManager).GetProperty(nameof(ServerLibraryManager.IsScanRunning))!.SetValue(manager, scanRunning); + + await manager.StartScanInBackground().ConfigureAwait(true); + + tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), scanRunning ? Times.Never() : Times.Once()); + tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Never()); + } + + [Fact] + public async Task ValidateMediaLibrary_RestartsScheduledScan() + { + 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"); + var tasks = fixture.Freeze<Mock<ITaskManager>>(); + var manager = fixture.Create<ServerLibraryManager>(); + + await manager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(true); + + tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Once()); + tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), Times.Never()); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index c80f899498..131cb23fa4 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -7,7 +7,9 @@ using Castle.Components.DictionaryAdapter; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaSegments; @@ -149,6 +151,73 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex); } + [Theory] + // A remembered full track must not survive a switch to "only forced" (it falls through to + // the forced track here); a remembered forced track and "off" still must. + [InlineData(SubtitlePlaybackMode.OnlyForced, 2, 3)] + [InlineData(SubtitlePlaybackMode.OnlyForced, 3, 3)] + [InlineData(SubtitlePlaybackMode.OnlyForced, -1, -1)] + [InlineData(SubtitlePlaybackMode.Default, 2, 2)] + [InlineData(SubtitlePlaybackMode.Always, 2, 2)] + [InlineData(SubtitlePlaybackMode.Smart, 2, 2)] + [InlineData(SubtitlePlaybackMode.None, 2, null)] + public void SetDefaultSubtitleStreamIndex_RememberedSelection_RespectsSubtitleMode( + SubtitlePlaybackMode mode, + int rememberedIndex, + int? expectedIndex) + { + _mockUserDataManager + .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = rememberedIndex }); + + var mediaInfo = new MediaSourceInfo + { + MediaStreams = new MediaStream[] + { + new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true }, + new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true }, + new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false }, + new() { Index = 3, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = false, IsForced = true } + } + }; + + _user.SubtitleMode = mode; + _user.SubtitleLanguagePreference = string.Empty; + _user.RememberSubtitleSelections = true; + _user.AudioLanguagePreference = string.Empty; + + _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); + + Assert.Equal(expectedIndex, mediaInfo.DefaultSubtitleStreamIndex); + } + + [Fact] + public void SetDefaultSubtitleStreamIndex_OnlyForcedRemembersFullTrackWithNoForcedStream_SelectsNothing() + { + _mockUserDataManager + .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = 2 }); + + var mediaInfo = new MediaSourceInfo + { + MediaStreams = new MediaStream[] + { + new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true }, + new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true }, + new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false } + } + }; + + _user.SubtitleMode = SubtitlePlaybackMode.OnlyForced; + _user.SubtitleLanguagePreference = string.Empty; + _user.RememberSubtitleSelections = true; + _user.AudioLanguagePreference = string.Empty; + + _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); + + Assert.Null(mediaInfo.DefaultSubtitleStreamIndex); + } + [Fact] public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion() { @@ -195,6 +264,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library } [Fact] + public void GetStaticMediaSources_ItemWithoutMediaSources_ThrowsArgumentException() + { + // A container queued by mistake is a bad request, not a server fault. + Assert.Throws<ArgumentException>( + () => _mediaSourceManager.GetStaticMediaSources(new MusicArtist { Id = Guid.NewGuid() }, false, _user)); + } + + [Fact] public void GetStaticMediaSources_NoUser_DoesNotTouchUserData() { var (primary, _, _) = SetupVersionGroup(); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs new file mode 100644 index 0000000000..421671b520 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Data; +using Emby.Server.Implementations.Library.SimilarItems; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Tests.Item; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +/// <summary> +/// Covers how <see cref="MovieSimilarItemsProvider"/> treats alternate versions: they share their +/// primary's genres, tags, studios and people, so they score like it and must not be offered as +/// something similar - neither as another copy of a recommendation nor as a match for the source. +/// </summary> +public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture +{ + private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private readonly MovieSimilarItemsProvider _provider; + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly User _user = new("test", "auth-provider", "reset-provider"); + private readonly string _movieTypeName; + private readonly string _folderTypeName; + + private readonly Guid _source = Guid.NewGuid(); + private readonly Guid _sourceAlternate = Guid.NewGuid(); + private readonly Guid _similar = Guid.NewGuid(); + private readonly Guid _similarAlternate = Guid.NewGuid(); + private readonly Guid _unrelated = Guid.NewGuid(); + + // A second scenario, in two libraries and on a genre of its own, for the group whose primary the + // user may not be able to reach at all. + private readonly Guid _crossSource = Guid.NewGuid(); + private readonly Guid _crossLibraryPrimary = Guid.NewGuid(); + private readonly Guid _crossLibraryVersion = Guid.NewGuid(); + private readonly Guid _sameLibraryPrimary = Guid.NewGuid(); + private readonly Guid _sameLibraryVersion = Guid.NewGuid(); + + public MovieSimilarItemsProviderTests() + { + var itemTypeLookup = new ItemTypeLookup(); + _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!; + _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!; + + using (var context = CreateDbContext()) + { + Seed(context); + } + + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + _provider = new MovieSimilarItemsProvider( + CreateDbContextFactory(), + CreateBaseItemRepository(itemTypeLookup), + serverConfigurationManager.Object, + _libraryManager.Object); + } + + [Fact] + public async Task GetSimilarItems_ReturnsThePrimaryAndNeitherVersionOfTheSource() + { + var items = await GetSimilarItemsAsync().ConfigureAwait(true); + + Assert.Equal([_similar], items); + } + + [Fact] + public async Task GetSimilarItems_DoesNotOfferAnAlternateVersionOfAMatch() + { + var items = await GetSimilarItemsAsync().ConfigureAwait(true); + + Assert.DoesNotContain(_similarAlternate, items); + } + + [Fact] + public async Task GetSimilarItems_DoesNotOfferTheSourcesOwnOtherVersion() + { + var items = await GetSimilarItemsAsync().ConfigureAwait(true); + + Assert.DoesNotContain(_sourceAlternate, items); + } + + [Fact] + public async Task GetSimilarItems_UserWithoutThePrimarysLibrary_OffersTheVersion() + { + // The user may only open the library the 1080p version is in, so its primary is out of reach + // and the version is all that is left to stand in for the group. + RestrictUserTo(_movieLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_crossLibraryVersion, items); + Assert.DoesNotContain(_crossLibraryPrimary, items); + } + + [Fact] + public async Task GetSimilarItems_UserWithBothLibraries_OffersThePrimaryOfTheGroupOnce() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_crossLibraryPrimary, items); + Assert.DoesNotContain(_crossLibraryVersion, items); + } + + [Fact] + public async Task GetSimilarItems_GroupMergedInsideOneLibrary_StillOffersOnlyThePrimary() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_sameLibraryPrimary, items); + Assert.DoesNotContain(_sameLibraryVersion, items); + } + + private void RestrictUserTo(params Guid[] libraryIds) + { + _libraryManager + .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>())) + .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds); + } + + private async Task<List<Guid>> GetSimilarItemsAsync(Guid? sourceId = null) + { + var results = await _provider.GetSimilarItemsAsync( + new Movie { Id = sourceId ?? _source, Name = "Source" }, + new SimilarItemsQuery { User = _user, Limit = 10, DtoOptions = new DtoOptions() }, + CancellationToken.None).ConfigureAwait(false); + + return results.Select(i => i.Id).ToList(); + } + + private void Seed(JellyfinDbContext context) + { + // One shared genre, so every movie but the unrelated one scores against the source. + var shared = CreateItemValue("Action", "action"); + var other = CreateItemValue("Comedy", "comedy"); + + var source = AddMovie(context, _source, "Source", primaryVersionId: null); + var sourceAlternate = AddMovie(context, _sourceAlternate, "Source 4K", primaryVersionId: _source); + var similar = AddMovie(context, _similar, "Similar", primaryVersionId: null); + var similarAlternate = AddMovie(context, _similarAlternate, "Similar 4K", primaryVersionId: _similar); + var unrelated = AddMovie(context, _unrelated, "Unrelated", primaryVersionId: null); + + // The second scenario scores on a genre of its own, so it stays out of the results above. + var crossLibrary = CreateItemValue("Science Fiction", "science fiction"); + + AddLibrary(context, _movieLibraryId, "Movies"); + AddLibrary(context, _movie4KLibraryId, "Movies-4K"); + + var crossSource = AddMovie(context, _crossSource, "Cross Source", primaryVersionId: null, libraryId: _movieLibraryId); + + // The 4K version heads the group and lives in a library of its own. + var crossLibraryPrimary = AddMovie(context, _crossLibraryPrimary, "Coco 4K", primaryVersionId: null, libraryId: _movie4KLibraryId); + var crossLibraryVersion = AddMovie(context, _crossLibraryVersion, "Coco", primaryVersionId: _crossLibraryPrimary, libraryId: _movieLibraryId); + + // A group merged inside one library, as a control. + var sameLibraryPrimary = AddMovie(context, _sameLibraryPrimary, "Up 4K", primaryVersionId: null, libraryId: _movieLibraryId); + var sameLibraryVersion = AddMovie(context, _sameLibraryVersion, "Up", primaryVersionId: _sameLibraryPrimary, libraryId: _movieLibraryId); + + context.Users.Add(_user); + context.ItemValues.AddRange(shared, other, crossLibrary); + context.ItemValuesMap.AddRange( + CreateMap(source, shared), + CreateMap(sourceAlternate, shared), + CreateMap(similar, shared), + CreateMap(similarAlternate, shared), + CreateMap(unrelated, other), + CreateMap(crossSource, crossLibrary), + CreateMap(crossLibraryPrimary, crossLibrary), + CreateMap(crossLibraryVersion, crossLibrary), + CreateMap(sameLibraryPrimary, crossLibrary), + CreateMap(sameLibraryVersion, crossLibrary)); + + context.SaveChanges(); + } + + private void AddLibrary(JellyfinDbContext context, Guid id, string name) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = _folderTypeName, + Name = name, + Path = "/" + name, + IsFolder = true + }); + } + + private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId, Guid? libraryId = null) + { + var item = new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + SortName = name, + ParentId = libraryId, + TopParentId = libraryId, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false, + // An alternate presents under its primary's key, which is what collapses the group in listings. + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"), + PrimaryVersionId = primaryVersionId + }; + + context.BaseItems.Add(item); + return item; + } + + private static ItemValue CreateItemValue(string value, string cleanValue) + => new() + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = value, + CleanValue = cleanValue + }; + + private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue) + => new() + { + ItemId = item.Id, + ItemValueId = itemValue.ItemValueId, + Item = item, + ItemValue = itemValue + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs new file mode 100644 index 0000000000..30f7bed208 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.Library.Validators; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +/// <summary> +/// Tests for how the people validator decides which credits need a person item and which person items +/// nothing credits any more. Keying either half on the item's name rather than its id put the two halves +/// in a loop that created, refreshed and deleted the same people on every run, so these pin the id. +/// </summary> +public class PeopleValidatorPartitionTests +{ + // Stands in for the real item-by-name id: derived from the credit name, case-insensitively, and + // from nothing else. The property that matters is that it does not depend on the item's own name. + private static Guid PersonId(string creditName) + { +#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms + var hash = System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.Unicode.GetBytes(creditName.ToLowerInvariant())); +#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms + return new Guid(hash); + } + + [Fact] + public void PartitionCreditsByPersonId_ProviderRenamedThePerson_KeepsThemAndCreatesNothing() + { + // The credit still says "AURORA"; the item it made has been renamed to "Aurora" by the provider + // that refreshed it. Nothing about the library changed, so nothing should be created or deleted. + var credits = new[] { "AURORA" }; + var existing = new HashSet<Guid> { PersonId("AURORA") }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + + Assert.Empty(newNames); + Assert.Empty(deadIds); + } + + [Theory] + // Every shape of rename seen in the wild on a real library. + [InlineData("AURORA")] + [InlineData("Amir AboulEla")] + [InlineData("Miguel Ángel Fuentes")] + [InlineData("a‐ha")] + [InlineData("윤현민")] + public void PartitionCreditsByPersonId_CreditWithAnItem_IsNeverBothCreatedAndDeleted(string creditName) + { + var existing = new HashSet<Guid> { PersonId(creditName) }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId([creditName], PersonId, existing); + + Assert.Empty(newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_CreditWithNoItem_IsCreated() + { + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId( + ["Wanted Person"], + PersonId, + new HashSet<Guid>()); + + Assert.Equal(["Wanted Person"], newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_ItemNoCreditNames_IsDead() + { + var orphan = PersonId("Nobody Credits Me"); + var existing = new HashSet<Guid> { PersonId("Credited"), orphan }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(["Credited"], PersonId, existing); + + Assert.Empty(newNames); + Assert.Equal([orphan], deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_CreditsNormalizingOntoOneId_CreateOneItem() + { + // "AURORA" and "Aurora" are one person as far as the item-by-name id is concerned, so exactly + // one of them should create the item and neither should end up dead. + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId( + ["AURORA", "Aurora", "aurora"], + PersonId, + new HashSet<Guid>()); + + Assert.Single(newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_SecondRunAfterCreating_AsksForNothingFurther() + { + // The churn showed up as a run that never settled, so drive two rounds: whatever round one + // created must leave round two with nothing to do. + string[] credits = ["AURORA", "Amir AboulEla", "Miguel Ángel Fuentes"]; + var existing = new HashSet<Guid>(); + + var (firstNames, firstDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + Assert.Equal(3, firstNames.Count); + Assert.Empty(firstDead); + + foreach (var created in firstNames) + { + existing.Add(PersonId(created)); + } + + var (secondNames, secondDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + + Assert.Empty(secondNames); + Assert.Empty(secondDead); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs new file mode 100644 index 0000000000..5aa770b9b3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Data; +using Emby.Server.Implementations.Library.Search; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.Implementations.Tests.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +/// <summary> +/// Covers what <see cref="SqlSearchProvider"/> returns for a version group merged across two +/// libraries: the primary represents the group wherever it is visible, and the version stands in +/// for it for a user who cannot open the library the primary lives in. +/// </summary> +public sealed class SqlSearchProviderTests : SqliteDbTestFixture +{ + private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static readonly Guid _primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + private static readonly Guid _versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + private readonly SqlSearchProvider _provider; + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + public SqlSearchProviderTests() + { + var itemTypeLookup = new ItemTypeLookup(); + var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!; + var folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!; + + using (var context = CreateDbContext()) + { + context.Users.Add(_user); + context.BaseItems.Add(CreateLibrary(_movieLibraryId, folderTypeName, "Movies", "/movies")); + context.BaseItems.Add(CreateLibrary(_movie4KLibraryId, folderTypeName, "Movies-4K", "/movies-4k")); + context.BaseItems.Add(CreateMovie(_primaryId, movieTypeName, _movie4KLibraryId, null)); + context.BaseItems.Add(CreateMovie(_versionId, movieTypeName, _movieLibraryId, _primaryId)); + context.SaveChanges(); + } + + var userManager = new Mock<IUserManager>(); + userManager.Setup(u => u.GetUserById(_user.Id)).Returns(_user); + + _provider = new SqlSearchProvider( + CreateDbContextFactory(), + itemTypeLookup, + _libraryManager.Object, + userManager.Object, + CreateBaseItemRepository(itemTypeLookup)); + } + + [Fact] + public async Task SearchAsync_UserWithoutThePrimarysLibrary_FindsTheVersion() + { + RestrictUserTo(_movieLibraryId); + + var hits = await SearchAsync().ConfigureAwait(true); + + Assert.Equal([_versionId], hits); + } + + [Fact] + public async Task SearchAsync_UserWithBothLibraries_FindsThePrimaryOnce() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var hits = await SearchAsync().ConfigureAwait(true); + + Assert.Equal([_primaryId], hits); + } + + private void RestrictUserTo(params Guid[] libraryIds) + { + _libraryManager + .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>())) + .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds); + } + + private async Task<List<Guid>> SearchAsync() + { + var results = await _provider.SearchAsync( + new SearchProviderQuery { SearchTerm = "coco", UserId = _user.Id, Limit = 10 }, + CancellationToken.None).ConfigureAwait(false); + + return results.Select(r => r.ItemId).ToList(); + } + + private static BaseItemEntity CreateLibrary(Guid id, string typeName, string name, string path) + => new() + { + Id = id, + Type = typeName, + Name = name, + Path = path, + IsFolder = true + }; + + private static BaseItemEntity CreateMovie(Guid id, string typeName, Guid libraryId, Guid? primaryVersionId) + => new() + { + Id = id, + Type = typeName, + Name = "Coco", + CleanName = "coco", + SortName = "Coco", + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false, + ParentId = libraryId, + TopParentId = libraryId, + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"), + PrimaryVersionId = primaryVersionId + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index d973076ed3..93014e7244 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -199,6 +199,16 @@ namespace Jellyfin.Server.Implementations.Tests.Localization [InlineData("Rated: R", "US", 17, 0)] [InlineData("Rated R", "US", 17, 0)] [InlineData(" PG-13 ", "US", 13, 0)] + [InlineData("T", "IT", 0, null)] + [InlineData("VM6", "IT", 6, null)] + [InlineData("VM12", "IT", 12, null)] + [InlineData("VM14", "IT", 14, null)] + [InlineData("VM18", "IT", 18, null)] + [InlineData("IT-VM14", "IT", 14, null)] // TMDB style country prefix + [InlineData("IT-VM18", "IT", 18, null)] + [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)] public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration() @@ -213,6 +223,30 @@ namespace Jellyfin.Server.Implementations.Tests.Localization } [Theory] + // Rating strings are stored mixed-case in the *.json rating systems and must match regardless of casing + [InlineData("btl", "se", 0, null)] // Direct lookup, lowercase of "Btl" + [InlineData("BARNTILLÅTEN", "se", 0, null)] // Direct lookup, uppercase incl. diacritics + [InlineData("SE-BTL", "se", 0, null)] // Country prefix stripped against the configured country + [InlineData("SE-BTL", "us", 0, null)] // Country prefix resolved via the separator fallback + [InlineData("Från 7 År", "se", 7, null)] // Diacritic casing (json has "Från 7 år") + [InlineData("SE-Från 7 År", "us", 7, null)] // Same, via the separator fallback + [InlineData("fsk-16", "de", 16, null)] // Not Sweden specific: lowercase of "FSK-16" + public async Task GetRatingScore_IsCaseInsensitive_Success(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); + } + + [Theory] [InlineData("0", 0, null)] [InlineData("1", 1, null)] [InlineData("6", 6, null)] @@ -241,6 +275,51 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Null(localizationManager.GetRatingScore("unrated")); Assert.Null(localizationManager.GetRatingScore("Not Rated")); Assert.Null(localizationManager.GetRatingScore("n/a")); + Assert.Null(localizationManager.GetRatingScore("N/A")); + Assert.Null(localizationManager.GetRatingScore(" n/a ")); + } + + [Theory] + // "NR" and "UR" are rating strings of some systems, so they must stay unrated when listed alongside others + [InlineData("NR / R", 17, 0)] + [InlineData("unrated / R", 17, 0)] + [InlineData("R / NR", 17, 0)] + public async Task GetRatingLevel_SkipsUnratedListEntries_Success(string value, int? expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "us" }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(value); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + + [Theory] + // Ratings that contain a '/' themselves must not be split into a list of ratings + [InlineData("M/3", "pt", 3, null)] + [InlineData("M/12", "pt", 12, null)] + [InlineData("M/18", "pt", 18, null)] + [InlineData("PT-M/12", "pt", 12, null)] // TMDB style country prefix + [InlineData("M/12", "us", 12, null)] // Resolved through the all-systems fallback + [InlineData("U/A 13+", "in", 13, null)] + [InlineData("7/i", "es", 11, null)] + [InlineData("7/i/fig", "es", 11, null)] + [InlineData("18/fig", "es", 18, null)] + public async Task GetRatingScore_RatingContainingSlash_IsNotSplit(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); } [Theory] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index 265b6a7f43..ee41b968e1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -240,6 +240,28 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins } [Fact] + public async Task PopulateManifest_ExistingImage_IsNotDownloaded() + { + const string ImageContent = "not really a png"; + + var packageInfo = GenerateTestPackage(); + packageInfo.ImageUrl = "https://example.org/some-plugin.png"; + + var imagePath = Path.Combine(_pluginPath, "some-plugin.png"); + await File.WriteAllTextAsync(imagePath, ImageContent, TestContext.Current.CancellationToken); + + // The application host is null, so attempting to download the image would throw. + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, null!, new Version(1, 0)); + + Assert.True(await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active)); + + var result = pluginManager.LoadManifest(_pluginPath).Manifest; + + Assert.Equal(imagePath, result.ImagePath); + Assert.Equal(ImageContent, await File.ReadAllTextAsync(imagePath, TestContext.Current.CancellationToken)); + } + + [Fact] public async Task PopulateManifest_ExistingMetafileMismatchedIds_Status_Malfunctioned() { var packageInfo = GenerateTestPackage(); diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs new file mode 100644 index 0000000000..a07e79baa3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class PlayCommandQueueTests : IDisposable +{ + private readonly ILibraryManager? _previousLibraryManager; + + public PlayCommandQueueTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + } + + /// <summary> + /// A music genre tags its artists as well as their songs, and a by-name artist row is not a + /// 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> + [Fact] + public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems() + { + var genre = new MusicGenre { Id = Guid.NewGuid(), Name = "Reggaeton" }; + var song = new Audio { Id = Guid.NewGuid(), Name = "Me Porto Bonito" }; + var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "NATTI NATASHA" }; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(i => i.GetItemById(genre.Id)).Returns(genre); + libraryManager + .Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(new List<BaseItem> { artist, song }); + BaseItem.LibraryManager = libraryManager.Object; + + await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + Mock.Of<IEventManager>(), + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + libraryManager.Object, + Mock.Of<IUserManager>(), + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + + var session = await sessionManager.LogSessionActivity("app_name", "0.0.0", "device_id", "device_name", "127.0.0.1", null); + + var command = new PlayRequest + { + ItemIds = new[] { genre.Id }, + PlayCommand = PlayCommand.PlayNow + }; + + await sessionManager.SendPlayCommand(null, session.Id, command, CancellationToken.None); + + Assert.Equal(new[] { song.Id }, command.ItemIds); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + BaseItem.LibraryManager = _previousLibraryManager!; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs index 32685556b2..05e8a40de1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs @@ -143,6 +143,36 @@ public class PlayQueueManagerTests } [Fact] + public void SetShuffleMode_SortedWhileAlreadySorted_KeepsPlayingItem() + { + var queue = CreateQueue(3); + queue.SetPlayingItemByIndex(1); + var expectedItemId = queue.GetPlayingItemId(); + + queue.SetShuffleMode(GroupShuffleMode.Sorted); + + Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode); + Assert.Equal(1, queue.PlayingItemIndex); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] + public void SetShuffleMode_SortedTwiceAfterShuffle_KeepsPlayingItem() + { + var queue = CreateQueue(5); + queue.SetPlayingItemByIndex(2); + var expectedItemId = queue.GetPlayingItemId(); + + queue.SetShuffleMode(GroupShuffleMode.Shuffle); + queue.SetShuffleMode(GroupShuffleMode.Sorted); + queue.SetShuffleMode(GroupShuffleMode.Sorted); + + Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode); + Assert.Equal(5, queue.GetPlaylist().Count); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] public void SetPlayingItemByIndex_InBounds_SetsPlayingItem() { var queue = CreateQueue(2); diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs new file mode 100644 index 0000000000..ecd8fafe80 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group; +using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class SyncPlayManagerTests +{ + [Fact] + public void LeaveGroup_AfterJoiningTheSameGroupTwice_ClearsTheActiveSessionCounter() + { + var harness = new ManagerHarness(); + + var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + Assert.True(harness.Manager.IsUserActive(harness.User.Id)); + + // A client that re-sends Join for the group it is already in must not be counted twice. + harness.Manager.JoinGroup(harness.Session, new JoinGroupRequest(info.GroupId), CancellationToken.None); + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + [Fact] + public void LeaveGroup_AfterASingleJoin_ClearsTheActiveSessionCounter() + { + var harness = new ManagerHarness(); + + harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + [Fact] + public void IsUserActive_WithTwoSessionsOfTheSameUser_TracksBothSeparately() + { + var harness = new ManagerHarness(); + var second = harness.CreateSession("session-2"); + + var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None); + + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + Assert.True(harness.Manager.IsUserActive(harness.User.Id)); + + harness.Manager.LeaveGroup(second, new LeaveGroupRequest(), CancellationToken.None); + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + [Fact] + public async Task HandleRequest_GroupWaitsForAMemberThatNeverReportsReady_RecoversOnItsOwn() + { + var harness = new ManagerHarness(groupWaitTimeout: 200); + var second = harness.CreateSession("session-2"); + + var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None); + + // Starting playback puts the group behind the ready barrier. + harness.Manager.HandleRequest( + harness.Session, + new PlayGroupRequest(new[] { Guid.NewGuid() }, 0, 0), + CancellationToken.None); + Assert.Equal(GroupStateType.Waiting, harness.Manager.GetGroup(harness.Session, info.GroupId).State); + + // Neither session ever reports ready, so the group has to come out of the wait by itself. + Assert.Equal( + GroupStateType.Playing, + await harness.WaitForState(harness.Session, info.GroupId, GroupStateType.Playing)); + } + + private sealed class ManagerHarness + { + private readonly Mock<ISessionManager> _sessionManager = new(); + + public ManagerHarness(long? groupWaitTimeout = null) + { + var userManager = new Mock<IUserManager>(); + var libraryManager = new Mock<ILibraryManager>(); + + User = new User("tester", "auth-provider", "pwdreset-provider"); + userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User); + + var item = new Mock<BaseItem>(); + item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true); + item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks; + libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object); + + _sessionManager + .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + _sessionManager + .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + Manager = new SyncPlayManager( + NullLoggerFactory.Instance, + userManager.Object, + _sessionManager.Object, + libraryManager.Object) + { + GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout + }; + + Session = CreateSession("session-1"); + } + + public SyncPlayManager Manager { get; } + + public User User { get; } + + public SessionInfo Session { get; } + + public SessionInfo CreateSession(string id) + { + return new SessionInfo(_sessionManager.Object, NullLogger.Instance) + { + Id = id, + UserId = User.Id, + UserName = User.Username + }; + } + + public async Task<GroupStateType> WaitForState(SessionInfo session, Guid groupId, GroupStateType expected) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + GroupStateType state; + while ((state = Manager.GetGroup(session, groupId).State) != expected && DateTime.UtcNow < deadline) + { + await Task.Delay(20, TestContext.Current.CancellationToken); + } + + return state; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs new file mode 100644 index 0000000000..d3cbc9b8be --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class WaitingGroupStateTests +{ + [Fact] + public void Ready_PlayingSessionReportsPositionFromBeforeSeek_IsCorrected() + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(10).Ticks; + group.LastActivity = DateTime.UtcNow; + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + + // One member seeks half an hour in. + state.HandleRequest( + new SeekGroupRequest(TimeSpan.FromMinutes(40).Ticks), + group, + GroupStateType.Playing, + harness.Second, + CancellationToken.None); + + harness.Commands.Clear(); + + // The other member has not applied the seek yet and reports the old position, still playing. + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, TimeSpan.FromMinutes(10).Ticks, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + // It must be seeked into position, not accepted as ready and handed a pause command + // scheduled the length of the seek into the future. + Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek); + Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Pause); + Assert.True(group.IsBuffering(), "session should still be considered buffering"); + } + + [Fact] + public void Ready_PlayingSessionRecoveringFromALongStall_IsNotSeeked() + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(10).Ticks; + group.LastActivity = DateTime.UtcNow; + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + + // The session reports it is buffering. No seek happens, so the group position stays put. + state.HandleRequest( + new BufferGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId), + group, + GroupStateType.Playing, + harness.First, + CancellationToken.None); + + harness.Commands.Clear(); + + // It recovers 45 seconds later, still behind, and must be waited for rather than seeked + // forward past content it already buffered. + var behind = group.PositionTicks - TimeSpan.FromSeconds(45).Ticks; + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, behind, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek); + } + + [Fact] + public void Ready_PlayingSessionSlightlyBehindGroup_IsStillTreatedAsCatchingUp() + { + var harness = new GroupHarness(); + var group = harness.Group; + + // A session that is a couple of seconds behind is genuinely recovering, and the group + // is expected to wait for it rather than seek it around. + group.PositionTicks = TimeSpan.FromMinutes(30).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetBuffering(harness.First, true); + group.SetBuffering(harness.Second, true); + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + harness.Commands.Clear(); + + var clientPosition = group.PositionTicks - TimeSpan.FromSeconds(2).Ticks; + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, clientPosition, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek); + Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Pause); + } + + [Fact] + public void Ready_PausedSessionOutOfPosition_IsStillCorrected() + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(30).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetBuffering(harness.First, true); + group.SetBuffering(harness.Second, true); + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + harness.Commands.Clear(); + + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek); + } + + [Fact] + public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds() + { + var harness = new GroupHarness(); + var group = harness.Group; + + // Both members report a ping well under the default, so the floor is what decides the delay. + group.UpdatePing(harness.First, 10); + group.UpdatePing(harness.Second, 10); + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetBuffering(harness.First, true); + group.SetBuffering(harness.Second, false); + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + + var before = DateTime.UtcNow; + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + // DefaultPing is expressed in milliseconds, so the floor must be converted before being + // compared against a tick count. Without the conversion the floor is 500 ticks (0.05 ms) + // and never applies. + var scheduledDelay = group.LastActivity - before; + Assert.True( + scheduledDelay >= TimeSpan.FromMilliseconds(group.DefaultPing), + $"expected a resume delay of at least {group.DefaultPing} ms, got {scheduledDelay.TotalMilliseconds} ms"); + } + + [Theory] + [InlineData(4_000_000_000L)] + [InlineData(1_000_000_000_000_000L)] + [InlineData(long.MaxValue)] + [InlineData(-1L)] + public void UpdatePing_ClientReportsAnUnusablePing_IsClampedAndCannotStallTheGroup(long reportedPing) + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.UpdatePing(harness.First, reportedPing); + + Assert.InRange(group.GetHighestPing(), 0, group.MaxPing); + + // The reported ping is scaled into the group's resume point, so an unclamped value either + // pushes playback months out or overflows the arithmetic outright. + var state = new PlayingGroupState(NullLoggerFactory.Instance); + var before = DateTime.UtcNow; + state.HandleRequest( + new UnpauseGroupRequest(), + group, + GroupStateType.Paused, + harness.First, + CancellationToken.None); + + Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1)); + } + + [Fact] + public async Task SessionJoined_JoinerNeverReportsReady_GroupResumesWithoutIt() + { + var harness = new GroupHarness(groupWaitTimeout: 200); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetState(new PlayingGroupState(NullLoggerFactory.Instance)); + + // A session joins while the group is playing: the group pauses and waits for it. + var joiner = harness.NewSession("joiner"); + group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None); + + Assert.Equal(GroupStateType.Waiting, group.GetInfo().State); + + // The joiner's player aborts and never reports ready. Without a bounded wait the whole + // group stays paused forever. + await harness.WaitForState(GroupStateType.Playing); + + // Late buffer reports from the session that missed the deadline must not drag the group + // back into waiting. + group.HandleRequest( + joiner, + new BufferGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId), + CancellationToken.None); + + Assert.Equal(GroupStateType.Playing, group.GetInfo().State); + } + + [Fact] + public async Task SessionJoined_GroupWasPaused_TimeoutLeavesTheGroupPaused() + { + var harness = new GroupHarness(groupWaitTimeout: 200); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + + // The group has been sitting paused for a while before anyone joins. + group.LastActivity = DateTime.UtcNow.AddMinutes(-2); + group.SetState(new PausedGroupState(NullLoggerFactory.Instance)); + + var joiner = harness.NewSession("joiner"); + group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None); + + Assert.Equal(GroupStateType.Waiting, group.GetInfo().State); + + // A group that was paused must not start playing because a member failed to report ready. + await harness.WaitForState(GroupStateType.Paused); + + // Giving up on the joiner must not move the playback position of an already paused group. + Assert.Equal(TimeSpan.FromMinutes(5).Ticks, group.PositionTicks); + + // Every member has to be told the group is no longer waiting. + var recipients = harness.StateUpdates + .Where(update => update.Update.State == GroupStateType.Paused) + .Select(update => update.SessionId) + .ToList(); + Assert.Contains(harness.First.Id, recipients); + Assert.Contains(harness.Second.Id, recipients); + Assert.Contains(joiner.Id, recipients); + } + + [Fact] + public async Task Ready_ReportedBeforeTheDeadline_GroupDoesNotGiveUpOnAnyone() + { + var harness = new GroupHarness(groupWaitTimeout: 200); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetState(new PlayingGroupState(NullLoggerFactory.Instance)); + + var joiner = harness.NewSession("joiner"); + group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None); + Assert.Equal(GroupStateType.Waiting, group.GetInfo().State); + + group.HandleRequest( + joiner, + new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId), + CancellationToken.None); + + // Everyone reported ready, so no deadline is left to trip and force a spurious unpause. + Assert.Equal(GroupStateType.Playing, group.GetInfo().State); + Assert.Null(group.GroupWaitDeadline); + + var until = DateTime.UtcNow.AddMilliseconds(3 * 200); + while (DateTime.UtcNow < until) + { + harness.PumpGroupWaitTimeout(); + await Task.Delay(20, TestContext.Current.CancellationToken); + } + + Assert.Equal(GroupStateType.Playing, group.GetInfo().State); + } + + [Fact] + public async Task SetPlaylistItem_AfterATimeout_GroupWaitsForEveryoneAgain() + { + var harness = new GroupHarness(groupWaitTimeout: 200); + var group = harness.Group; + + group.LastActivity = DateTime.UtcNow; + group.SetState(new PlayingGroupState(NullLoggerFactory.Instance)); + + var joiner = harness.NewSession("joiner"); + group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None); + await harness.WaitForState(GroupStateType.Playing); + + // Giving up on a session lasts only until the group changes what it is playing. + group.HandleRequest( + harness.First, + new SetPlaylistItemGroupRequest(harness.PlaylistItemId), + CancellationToken.None); + + Assert.Equal(GroupStateType.Waiting, group.GetInfo().State); + Assert.NotNull(group.GroupWaitDeadline); + } + + private sealed class GroupHarness + { + private readonly ISessionManager _sessionManager; + private readonly Guid _userId; + + public GroupHarness(long? groupWaitTimeout = null) + { + var userManager = new Mock<IUserManager>(); + var sessionManager = new Mock<ISessionManager>(); + var libraryManager = new Mock<ILibraryManager>(); + + var user = new User("tester", "auth-provider", "pwdreset-provider"); + userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(user); + + var item = new Mock<BaseItem>(); + item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true); + item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks; + libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object); + + sessionManager + .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>())) + .Callback<string, SendCommand, CancellationToken>((_, command, _) => Commands.Add(command)) + .Returns(Task.CompletedTask); + + sessionManager + .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>())) + .Callback((string sessionId, GroupUpdate<GroupStateUpdate> update, CancellationToken _) => StateUpdates.Add((sessionId, update.Data))) + .Returns(Task.CompletedTask); + + Group = new SyncPlayGroup( + NullLoggerFactory.Instance, + userManager.Object, + sessionManager.Object, + libraryManager.Object) + { + GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout + }; + + _sessionManager = sessionManager.Object; + _userId = user.Id; + + First = NewSession("first"); + Second = NewSession("second"); + + Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None); + Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None); + Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0); + PlaylistItemId = Group.PlayQueue.GetPlayingItemPlaylistId(); + } + + public SyncPlayGroup Group { get; } + + public List<(string SessionId, GroupStateUpdate Update)> StateUpdates { get; } = new(); + + public SessionInfo First { get; } + + public SessionInfo Second { get; } + + public Guid PlaylistItemId { get; } + + public List<SendCommand> Commands { get; } = new List<SendCommand>(); + + // Mirrors the sweep SyncPlayManager runs on a timer. + public void PumpGroupWaitTimeout() + { + var group = Group; + + // Group lock required as Group is not thread-safe. + lock (group) + { + group.HandleGroupWaitTimeout(CancellationToken.None); + } + } + + public async Task WaitForState(GroupStateType expected) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (Group.GetInfo().State != expected && DateTime.UtcNow < deadline) + { + PumpGroupWaitTimeout(); + await Task.Delay(20, TestContext.Current.CancellationToken); + } + + Assert.Equal(expected, Group.GetInfo().State); + } + + public SessionInfo NewSession(string id) + { + return new SessionInfo(_sessionManager, NullLogger.Instance) + { + Id = id, + UserId = _userId, + UserName = id + }; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs new file mode 100644 index 0000000000..5bb5081b60 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Entities.Security; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +public class DeviceAccessHostTests +{ + [Fact] + public async Task OnUserUpdated_LogoutThrows_DoesNotEscapeToThreadPool() + { + var user = new User("test", "default", "default"); + var device = new Device(user.Id, "app", "1.0", "device", "device-id"); + + var deviceManager = new Mock<IDeviceManager>(); + deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>())) + .Returns(new QueryResult<Device>(new[] { device })); + deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false); + + var sessionManager = new Mock<ISessionManager>(); + sessionManager.Setup(s => s.Logout(It.IsAny<Device>())) + .ThrowsAsync(new ObjectDisposedException(nameof(ISessionManager))); + + var userManager = new Mock<IUserManager>(); + var host = new DeviceAccessHost( + userManager.Object, + deviceManager.Object, + sessionManager.Object, + NullLogger<DeviceAccessHost>.Instance); + await host.StartAsync(TestContext.Current.CancellationToken); + + var context = new CapturingSynchronizationContext(); + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user)); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + + Assert.Empty(context.Exceptions); + } + + [Fact] + public async Task OnUserUpdated_DeviceNoLongerAllowed_LogsOutDevice() + { + var user = new User("test", "default", "default"); + var device = new Device(user.Id, "app", "1.0", "device", "device-id"); + + var deviceManager = new Mock<IDeviceManager>(); + deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>())) + .Returns(new QueryResult<Device>(new[] { device })); + deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false); + + var loggedOut = new TaskCompletionSource(); + var sessionManager = new Mock<ISessionManager>(); + sessionManager.Setup(s => s.Logout(It.IsAny<Device>())) + .Callback(() => loggedOut.TrySetResult()) + .Returns(Task.CompletedTask); + + var userManager = new Mock<IUserManager>(); + var host = new DeviceAccessHost( + userManager.Object, + deviceManager.Object, + sessionManager.Object, + NullLogger<DeviceAccessHost>.Instance); + await host.StartAsync(TestContext.Current.CancellationToken); + + userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user)); + + await loggedOut.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + sessionManager.Verify(s => s.Logout(device), Times.Once); + } + + private sealed class CapturingSynchronizationContext : SynchronizationContext + { + public List<Exception> Exceptions { get; } = new List<Exception>(); + + public override void Post(SendOrPostCallback d, object? state) => Run(d, state); + + public override void Send(SendOrPostCallback d, object? state) => Run(d, state); + + private void Run(SendOrPostCallback d, object? state) + { + try + { + d(state); + } + catch (Exception ex) + { + Exceptions.Add(ex); + } + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs index c940f92109..e91ebdf1b6 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.Sqlite; @@ -17,6 +18,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Model.Cryptography; +using MediaBrowser.Model.Users; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; @@ -120,6 +122,27 @@ public sealed class UserManagerUpdateUserTests : IDisposable } [Fact] + public async Task UpdatePolicyAsync_RaisesOnUserUpdated() + { + var user = await _userManager.CreateUserAsync("policyeventuser"); + + User? updated = null; + _userManager.OnUserUpdated += (_, e) => updated = e.Argument; + + await _userManager.UpdatePolicyAsync( + user.Id, + new UserPolicy + { + EnableAllDevices = false, + AuthenticationProviderId = user.AuthenticationProviderId, + PasswordResetProviderId = user.PasswordResetProviderId + }); + + Assert.NotNull(updated); + Assert.Equal(user.Id, updated.Id); + } + + [Fact] public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges() { var user = await _userManager.CreateUserAsync("policyuser"); diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs index 0a5838c545..79b9d1e2c5 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -6,8 +6,11 @@ using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Api.Models.LibraryStructureDto; using Jellyfin.Extensions.Json; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.DependencyInjection; using Xunit; using Xunit.v3.Priority; @@ -26,6 +29,45 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl } [Fact] + [Priority(-3)] + public async Task AddVirtualFolder_WithWarmDirectoryServiceCache_InvalidatesTheParentListing() + { + const string Name = "stale-cache-test"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var directoryService = _factory.Services.GetRequiredService<IDirectoryService>(); + var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath; + + // Cache a listing of the libraries root taken before the new folder exists. Everything + // resolving through this DirectoryService keeps reading that listing until it is dropped, + // so the library stays invisible. Making the caches shared once turned this into a real + // test failure, see UpdateLibraryOptions_Valid_Success. + Assert.DoesNotContain( + directoryService.GetFileSystemEntries(rootFolderPath), + x => string.Equals(x.Name, Name, StringComparison.Ordinal)); + + var body = new AddVirtualFolderDto() + { + LibraryOptions = new LibraryOptions() + { + Enabled = false + } + }; + + using var response = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + Assert.Contains( + directoryService.GetFileSystemEntries(rootFolderPath), + x => string.Equals(x.Name, Name, StringComparison.Ordinal)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } + + [Fact] [Priority(-1)] public async Task Post_NewVirtualFolder_NotFound() { diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs new file mode 100644 index 0000000000..f84e28de76 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs @@ -0,0 +1,36 @@ +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public sealed class SyncPlayControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + + public SyncPlayControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetGroups_Unauthorized_ReturnsUnauthorized() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetGroups_InvalidToken_ReturnsUnauthorized() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader("invalid-token"); + + var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs new file mode 100644 index 0000000000..81cff49945 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs @@ -0,0 +1,26 @@ +using Jellyfin.Server.Implementations.Users; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +public sealed class HostedServiceRegistrationTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + + public HostedServiceRegistrationTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public void DeviceAccessHost_IsRegisteredAsHostedService() + { + _ = _factory.CreateClient(); + + var hostedServices = _factory.Services.GetServices<IHostedService>(); + + Assert.Contains(hostedServices, service => service is DeviceAccessHost); + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs new file mode 100644 index 0000000000..3bd8581a5f --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Migrations; +using Jellyfin.Server.Migrations.Stages; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +public class CodeMigrationTests +{ + [Fact] + public async Task Perform_LeavesApplicationSingletonsAlive() + { + var services = new ServiceCollection() + .AddLogging() + .RegisterStartupLogger() + .AddSingleton<ApplicationSingleton>() + .AddTransient<MigrationTransient>(); + + await using var serviceProvider = services.BuildServiceProvider(); + var applicationSingleton = serviceProvider.GetRequiredService<ApplicationSingleton>(); + var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration"); + + var migration = new CodeMigration( + typeof(TestMigration), + new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)), + null); + await migration.Perform(serviceProvider, logger, CancellationToken.None); + + var performed = TestMigration.Performed; + Assert.NotNull(performed); + // The migration has to run against the applications own services, and they have to outlive it. + Assert.Same(applicationSingleton, performed.Singleton); + Assert.False(applicationSingleton.IsDisposed); + Assert.Same(applicationSingleton, serviceProvider.GetRequiredService<ApplicationSingleton>()); + // Services created for the migration itself are still owned by the migration. + Assert.True(performed.Transient.IsDisposed); + // The startup logger has to stay attached to the topic of the running migration. + Assert.Same(logger.Topic, performed.Logger.Topic); + } + + [Fact] + public async Task Perform_DoesNotLeakTheMigrationTopic() + { + var services = new ServiceCollection() + .AddLogging() + .RegisterStartupLogger() + .AddSingleton<ApplicationSingleton>() + .AddTransient<MigrationTransient>(); + + await using var serviceProvider = services.BuildServiceProvider(); + var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration"); + + var migration = new CodeMigration( + typeof(TestMigration), + new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)), + null); + await migration.Perform(serviceProvider, logger, CancellationToken.None); + + // The topic belongs to the migration that ran, so loggers resolved afterwards must not still write into it. + Assert.Null(serviceProvider.GetRequiredService<IStartupLogger<CodeMigrationTests>>().Topic); + Assert.Null(new StartupLogger(NullLogger.Instance).Topic); + } + + private sealed class ApplicationSingleton : IDisposable + { + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + } + + private sealed class MigrationTransient : IDisposable + { + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + } + + private sealed class TestMigration : IAsyncMigrationRoutine + { + public TestMigration(ApplicationSingleton singleton, MigrationTransient transient, IStartupLogger<TestMigration> logger) + { + Singleton = singleton; + Transient = transient; + Logger = logger; + } + + public static TestMigration? Performed { get; private set; } + + public ApplicationSingleton Singleton { get; } + + public MigrationTransient Transient { get; } + + public IStartupLogger<TestMigration> Logger { get; } + + public Task PerformAsync(CancellationToken cancellationToken) + { + Performed = this; + return Task.CompletedTask; + } + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs b/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs new file mode 100644 index 0000000000..25430447d4 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Migrations.Routines; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +/// <summary> +/// Covers the references a database carried up from 10.x can still hold against a view the migration +/// is about to drop. Only ParentId cascades; everything else here is a NO ACTION foreign key that +/// used to abort the migration, and with it the whole startup. +/// </summary> +public sealed class ConsolidateLocalizedUserViewsTests : IDisposable +{ + private const string MetadataPath = "/metadata"; + + private static readonly Guid _staleId = new("11111111-1111-1111-1111-111111111111"); + private static readonly Guid _canonicalId = new("22222222-2222-2222-2222-222222222222"); + + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + + public ConsolidateLocalizedUserViewsTests() + { + _connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task PerformAsync_ItemOwnedByStaleView_MovesItAndDropsTheView() + { + using (var context = CreateDbContext()) + { + context.BaseItems.Add(StaleView()); + context.BaseItems.Add(new BaseItemEntity { Id = Guid.NewGuid(), Type = "Trailer", OwnerId = _staleId }); + await context.SaveChangesAsync(Ct); + } + + await CreateMigration().PerformAsync(Ct); + + using (var context = CreateDbContext()) + { + Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct)); + Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Type == "Trailer", Ct)).OwnerId); + } + } + + [Fact] + public async Task PerformAsync_StaleViewInLinkedChildren_DropsTheLinksAndTheView() + { + var movieId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.Add(StaleView()); + context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = "Movie" }); + context.LinkedChildren.Add(new LinkedChildEntity { ParentId = _staleId, SortOrder = 0, ChildId = movieId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual }); + context.LinkedChildren.Add(new LinkedChildEntity { ParentId = movieId, SortOrder = 0, ChildId = _staleId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual }); + await context.SaveChangesAsync(Ct); + } + + await CreateMigration().PerformAsync(Ct); + + using (var context = CreateDbContext()) + { + Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct)); + Assert.Empty(context.LinkedChildren); + Assert.NotNull(await context.BaseItems.FindAsync([movieId], Ct)); + } + } + + [Fact] + public async Task PerformAsync_OrphanedAncestry_IsNotResurrectedUnderTheCanonicalView() + { + var childId = Guid.NewGuid(); + var orphanId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.Add(StaleView()); + context.BaseItems.Add(new BaseItemEntity { Id = childId, Type = "Movie", ParentId = _staleId }); + await context.SaveChangesAsync(Ct); + + context.AncestorIds.Add(new AncestorId { ItemId = childId, ParentItemId = _staleId, Item = null!, ParentItem = null! }); + await context.SaveChangesAsync(Ct); + + // Written while foreign keys went unenforced: the item behind it is long gone. + await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", Ct); + context.AncestorIds.Add(new AncestorId { ItemId = orphanId, ParentItemId = _staleId, Item = null!, ParentItem = null! }); + await context.SaveChangesAsync(Ct); + await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", Ct); + } + + await CreateMigration().PerformAsync(Ct); + + using (var context = CreateDbContext()) + { + Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct)); + Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Id.Equals(childId), Ct)).ParentId); + var ancestors = await context.AncestorIds.ToListAsync(Ct); + Assert.Equal(new[] { childId }, ancestors.Select(e => e.ItemId)); + Assert.Equal(_canonicalId, ancestors[0].ParentItemId); + } + } + + public void Dispose() + { + _connection.Dispose(); + GC.SuppressFinalize(this); + } + + private static BaseItemEntity StaleView() => new() + { + Id = _staleId, + Type = "MediaBrowser.Controller.Entities.UserView", + Path = Path.Combine(MetadataPath, "views", "livetv") + }; + + private JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(new Mock<IApplicationPaths>().Object, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + + private ConsolidateLocalizedUserViews CreateMigration() + { + var view = new UserView + { + Id = _staleId, + Path = Path.Combine(MetadataPath, "views", "livetv"), + Name = "Live TV", + ViewType = CollectionType.livetv + }; + + var applicationPaths = new Mock<IServerApplicationPaths>(); + applicationPaths.Setup(e => e.InternalMetadataPath).Returns(MetadataPath); + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager.Setup(e => e.ApplicationPaths).Returns(applicationPaths.Object); + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(e => e.GetValidFilename(It.IsAny<string>())).Returns((string name) => name); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(e => e.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(new List<BaseItem> { view }); + libraryManager.Setup(e => e.GetNewItemId(It.IsAny<string>(), It.IsAny<Type>())) + .Returns(_canonicalId); + libraryManager.Setup(e => e.CreateItem(It.IsAny<BaseItem>(), It.IsAny<BaseItem?>())) + .Callback((BaseItem item, BaseItem? parent) => + { + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = item.Id, + Type = item.GetType().FullName!, + Path = item.Path + }); + context.SaveChanges(); + }); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext); + + return new ConsolidateLocalizedUserViews( + new StartupLogger<ConsolidateLocalizedUserViews>(NullLogger<ConsolidateLocalizedUserViews>.Instance), + libraryManager.Object, + configurationManager.Object, + fileSystem.Object, + factory.Object); + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs b/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs new file mode 100644 index 0000000000..34eff8a988 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Migrations.Routines; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +/// <summary> +/// Covers the repair of the PrimaryVersionId the item queries hide an alternate version by, +/// including the link shapes that would otherwise leave a whole version group hidden. +/// </summary> +public sealed class RepairAlternateVersionLinksTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly IApplicationPaths _applicationPaths; + + public RepairAlternateVersionLinksTests() + { + _applicationPaths = new Mock<IApplicationPaths>().Object; + + // The connection owns the in-memory database, so it stays open for the whole test. + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + [Fact] + public async Task PerformAsync_VersionLinkedToPrimary_PointsItAtThePrimary() + { + var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + Seed( + [(primaryId, null), (versionId, null)], + [(primaryId, versionId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + AssertIsVersionOf(context, versionId, primaryId); + AssertIsPrimary(context, primaryId); + } + + [Fact] + public async Task PerformAsync_VideosLinkedAsEachOthersVersion_KeepsOneOfThemVisible() + { + var firstId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + // Each one claims the other as its version, so pointing both at their link would hide the + // group in its entirety. + Seed( + [(firstId, null), (secondId, null)], + [(firstId, secondId), (secondId, firstId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + var first = Get(context, firstId); + var second = Get(context, secondId); + + var primary = first.PrimaryVersionId is null ? first : second; + var version = first.PrimaryVersionId is null ? second : first; + + Assert.Null(primary.PrimaryVersionId); + AssertIsPrimary(context, primary.Id); + AssertIsVersionOf(context, version.Id, primary.Id); + } + + [Fact] + public async Task PerformAsync_PrimaryStillPointingAtItsOwnVersion_ClearsTheStalePrimary() + { + var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + // The primary was promoted over the version it now heads, but kept the pointer to it: the + // repair below would point the version back and leave both hidden. + Seed( + [(primaryId, versionId), (versionId, null)], + [(primaryId, versionId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + AssertIsPrimary(context, primaryId); + AssertIsVersionOf(context, versionId, primaryId); + } + + [Fact] + public async Task PerformAsync_ChainedLinks_PointsEveryVersionAtTheHeadOfTheChain() + { + var headId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var middleId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var tailId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + // The middle one is a version of the head and a primary of the tail at the same time. + Seed( + [(headId, null), (middleId, null), (tailId, null)], + [(headId, middleId), (middleId, tailId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + AssertIsPrimary(context, headId); + AssertIsVersionOf(context, middleId, headId); + AssertIsVersionOf(context, tailId, headId); + } + + [Fact] + public async Task PerformAsync_VersionLinkedToItself_LeavesItVisible() + { + var itemId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + Seed([(itemId, null)], [(itemId, itemId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + Assert.Null(Get(context, itemId).PrimaryVersionId); + } + + public void Dispose() + { + _connection.Dispose(); + } + + private static void AssertIsPrimary(JellyfinDbContext context, Guid id) + { + var item = Get(context, id); + Assert.Null(item.PrimaryVersionId); + Assert.Equal(id.ToString("N", CultureInfo.InvariantCulture), item.PresentationUniqueKey); + } + + private static void AssertIsVersionOf(JellyfinDbContext context, Guid id, Guid primaryId) + { + var item = Get(context, id); + Assert.Equal(primaryId, item.PrimaryVersionId); + + // Presentation-key grouping has to collapse the version onto its primary as well. + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), item.PresentationUniqueKey); + } + + private static BaseItemEntity Get(JellyfinDbContext context, Guid id) + => context.BaseItems.AsNoTracking().First(e => e.Id.Equals(id)); + + private JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + + private void Seed( + (Guid Id, Guid? PrimaryVersionId)[] items, + (Guid ParentId, Guid ChildId)[] links) + { + using var context = CreateDbContext(); + + foreach (var (id, primaryVersionId) in items) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = "MediaBrowser.Controller.Entities.Movies.Movie", + Name = "Movie", + PrimaryVersionId = primaryVersionId, + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N", CultureInfo.InvariantCulture) + }); + } + + foreach (var (parentId, childId) in links) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = parentId, + ChildId = childId, + ChildType = LinkedChildType.LinkedAlternateVersion + }); + } + + context.SaveChanges(); + } + + private Task PerformAsync() + { + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + var migration = new RepairAlternateVersionLinks( + new StartupLogger<RepairAlternateVersionLinks>(NullLogger<RepairAlternateVersionLinks>.Instance), + factory.Object); + + return migration.PerformAsync(CancellationToken.None); + } +} diff --git a/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs new file mode 100644 index 0000000000..c2894e9647 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs @@ -0,0 +1,54 @@ +using Jellyfin.Server.ServerSetupApp; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Tests.ServerSetupApp; + +public class StartupLoggerTests +{ + [Fact] + public void BeginAmbientTopic_AttachesNewLoggersToTheTopic() + { + var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration"); + + using (StartupLogger.BeginAmbientTopic(migration.Topic)) + { + Assert.Same(migration.Topic, new StartupLogger(NullLogger.Instance).Topic); + } + } + + [Fact] + public void BeginAmbientTopic_RestoresThePreviousTopic() + { + var root = new StartupLogger(NullLogger.Instance); + var outer = root.BeginGroup($"Outer"); + var inner = outer.BeginGroup($"Inner"); + + Assert.Null(new StartupLogger(NullLogger.Instance).Topic); + + using (StartupLogger.BeginAmbientTopic(outer.Topic)) + { + using (StartupLogger.BeginAmbientTopic(inner.Topic)) + { + Assert.Same(inner.Topic, new StartupLogger(NullLogger.Instance).Topic); + } + + // Leaving a nested topic has to fall back to the enclosing one, not to the setup UI root. + Assert.Same(outer.Topic, new StartupLogger(NullLogger.Instance).Topic); + } + + Assert.Null(new StartupLogger(NullLogger.Instance).Topic); + } + + [Fact] + public void BeginGroup_KeepsAnExplicitTopicOverTheAmbientOne() + { + var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration"); + var unrelated = new StartupLogger(NullLogger.Instance).BeginGroup($"Unrelated"); + + using (StartupLogger.BeginAmbientTopic(migration.Topic)) + { + Assert.Same(unrelated.Topic, unrelated.With(NullLogger.Instance).Topic); + } + } +} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs index a04b37f215..3767b5c954 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs @@ -124,6 +124,27 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers } [Fact] + public void Fetch_Valid_MultiEpisode_Unordered_Success() + { + var result = new MetadataResult<Episode>() + { + Item = new Episode() + }; + + _parser.Fetch(result, "Test Data/Rising-Reversed.nfo", CancellationToken.None); + + var item = result.Item; + // The episodedetails blocks are stored in descending order, the merged episode must still be in ascending order + Assert.Equal("Rising (1) / Rising (2)", item.Name); + Assert.Equal(1, item.IndexNumber); + Assert.Equal(2, item.IndexNumberEnd); + Assert.Equal(1, item.ParentIndexNumber); + Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy. / Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.", item.Overview); + Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate); + Assert.Equal(2004, item.ProductionYear); + } + + [Fact] public void Fetch_Valid_MultiEpisode_With_Missing_Tags_Success() { var result = new MetadataResult<Episode>() diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo new file mode 100644 index 0000000000..6dbab13566 --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo @@ -0,0 +1,43 @@ +<episodedetails> + <title>Rising (2)</title> + <season>1</season> + <episode>2</episode> + <aired>2004-07-16</aired> + <plot>Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.</plot> + <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25334.jpg</thumb> + <watched>false</watched> + <rating>7.9</rating> + <actor> + <name>Joe Flanigan</name> + <role>John Sheppard</role> + <order>0</order> + <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb> + </actor> + <actor> + <name>David Hewlett</name> + <role>Rodney McKay</role> + <order>1</order> + <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb> + </actor> +</episodedetails><episodedetails> + <title>Rising (1)</title> + <season>1</season> + <episode>1</episode> + <aired>2004-07-16</aired> + <plot>A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.</plot> + <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25333.jpg</thumb> + <watched>false</watched> + <rating>8.0</rating> + <actor> + <name>Joe Flanigan</name> + <role>John Sheppard</role> + <order>0</order> + <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb> + </actor> + <actor> + <name>David Hewlett</name> + <role>Rodney McKay</role> + <order>1</order> + <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb> + </actor> +</episodedetails> |
