diff options
147 files changed, 6814 insertions, 440 deletions
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 3473828484..12ee6411d3 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,6 +87,7 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: + - 12.1 - 12.0 - 10.11.11 - Master diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a97d335170..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) 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 59e75691dc..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; @@ -192,15 +193,11 @@ namespace Emby.Server.Implementations.Dto itemCountsBatch = GetItemCountsBatch(accessibleItems, user); } - // Batch-fetch child counts for all folders to avoid N+1 queries + // 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 @@ -714,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 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) { - return Random.Shared.Next(1, 10); + 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/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 0f92e2f03e..e51c863f86 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -3,6 +3,7 @@ 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; @@ -40,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> @@ -69,7 +76,7 @@ namespace Emby.Server.Implementations.IO _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; appLifetime.ApplicationStarted.Register(Start); - appLifetime.ApplicationStopping.Register(Stop); + appLifetime.ApplicationStopping.Register(Dispose); } /// <inheritdoc /> @@ -120,6 +127,11 @@ namespace Emby.Server.Implementations.IO /// <inheritdoc /> public void Start() { + if (_disposed) + { + return; + } + _libraryManager.ItemAdded += OnLibraryManagerItemAdded; _libraryManager.ItemRemoved += OnLibraryManagerItemRemoved; @@ -233,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(() => { @@ -256,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); @@ -357,6 +375,11 @@ namespace Emby.Server.Implementations.IO { ArgumentException.ThrowIfNullOrEmpty(path); + if (_disposed) + { + return; + } + if (IgnorePatterns.ShouldIgnore(path)) { return; @@ -452,6 +475,8 @@ namespace Emby.Server.Implementations.IO /// </summary> public void Stop() { + Interlocked.Increment(ref _watcherGeneration); + _libraryManager.ItemAdded -= OnLibraryManagerItemAdded; _libraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; @@ -496,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 0044fcd4dc..caba304888 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -318,7 +318,7 @@ namespace Emby.Server.Implementations.Library if (wizardChanged) { - _taskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(); + QueueLibraryScan(); } } @@ -878,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; + } } } @@ -3799,7 +3810,7 @@ namespace Emby.Server.Implementations.Library if (refreshLibrary) { - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -3867,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) @@ -3984,7 +3998,7 @@ namespace Emby.Server.Implementations.Library { await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -4128,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 b1547e72fe..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.Item.PrimaryVersionId.HasValue && 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,7 +286,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie if (personSourceRows.Count > 0) { var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() - .Where(m => !m.Item.PrimaryVersionId.HasValue) + .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/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/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index d44f0cc68d..acb78290f3 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -25,7 +25,7 @@ "NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.", "NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt", "NotificationOptionPluginInstalled": "Ískoytisforrit innlagt", - "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað", + "NotificationOptionPluginUninstalled": "Ískoytisforrit er strikað", "NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført", "NotificationOptionUserLockedOut": "Brúkari útihýstur", "Photos": "Ljósmyndir", @@ -96,9 +96,9 @@ "TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.", "TaskRefreshChapterImages": "Kapitlamyndaúttøkur", "NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað", - "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað", + "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl er steðgað", "NotificationOptionAudioPlayback": "Ljóðspæl byrjað", - "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað", + "NotificationOptionAudioPlaybackStopped": "Ljóðspæl er steðgað", "TaskExtractMediaSegments": "Leita eftir margmiðlabrotum", "TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.", "NotificationOptionCameraImageUploaded": "Ljósmynd uppsend", diff --git a/Emby.Server.Implementations/Localization/Core/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 2d38c173f0..c7e2b5f5e9 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -120,5 +120,6 @@ "NameExtraShort": "Kratki film", "NameExtraThemeSong": "Glavna Pjesma", "NameExtraThemeVideo": "Tema videa", - "NameExtraTrailer": "Trailer" + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Dodatno" } diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json index 21d31c5fbf..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", 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/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/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 923bfc67aa..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. @@ -115,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> @@ -163,6 +186,8 @@ namespace Emby.Server.Implementations.SyncPlay Ping = DefaultPing, IsBuffering = false }); + + _participantSessions[session.Id] = session; } /// <summary> @@ -172,6 +197,8 @@ namespace Emby.Server.Implementations.SyncPlay private void RemoveSession(SessionInfo session) { _participants.Remove(session.Id); + _participantSessions.Remove(session.Id); + UpdateGroupWaitDeadline(false); } /// <summary> @@ -389,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 /> @@ -475,6 +509,8 @@ namespace Emby.Server.Implementations.SyncPlay { value.IsBuffering = isBuffering; } + + UpdateGroupWaitDeadline(false); } /// <inheritdoc /> @@ -484,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 /> @@ -690,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 b88ee33358..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)) { @@ -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(); } } } @@ -384,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) 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 65bfe25d21..7f63e410a5 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -213,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(); @@ -260,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 { @@ -327,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/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.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 cdc8744642..51ac146a6f 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -117,6 +117,35 @@ public sealed partial class BaseItemRepository .ToArray(); } + /// <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 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() + .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(); 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 1ed10cce2b..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 /> diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index a745c3309f..d726f0f143 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -581,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) { @@ -807,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/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 57705cdf11..942161a176 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -414,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/> @@ -483,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) @@ -493,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) @@ -527,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) @@ -540,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 }) @@ -601,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) @@ -719,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 diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index c8672e189b..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(); @@ -268,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) { @@ -328,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) { @@ -657,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/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/Program.cs b/Jellyfin.Server/Program.cs index 35eaff6532..715fc3d3b9 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -271,9 +271,8 @@ namespace Jellyfin.Server // Don't throw additional exception if startup failed. if (appHost.ServiceProvider is not null) { - _logger.LogInformation("Optimizing the database... This might take a while"); + _logger.LogInformation("Preparing the database for shutdown..."); - // Deliberately untimed: a truncated optimization leaves the statistics incomplete. var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); await databaseProvider.RunShutdownTask(CancellationToken.None).ConfigureAwait(false); } 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.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 eb2a3676ac..7c88d5dd05 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -488,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 ac6df54949..82de3546f0 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -833,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/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/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index a0f4087395..6f010c0242 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -3807,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; @@ -3830,6 +3835,10 @@ namespace MediaBrowser.Controller.MediaEncoding tonemapArg += ":range=" + range.ToString().ToLowerInvariant(); } } + else if (forceFullRange) + { + formatArg += ":range=pc"; + } return string.Format( CultureInfo.InvariantCulture, @@ -5470,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) @@ -5540,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) 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/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/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs index f4fab29800..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); @@ -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.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/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 f64fd73763..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)) { 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/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 e7b15305b3..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 /> @@ -1314,6 +1316,7 @@ namespace MediaBrowser.Providers.Manager if (disposing) { CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated; + _configurationManager.ConfigurationUpdated -= OnConfigurationUpdated; if (!_disposeCancellationTokenSource.IsCancellationRequested) { @@ -1341,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(); @@ -1350,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/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/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index fb3e5ee92b..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) @@ -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 a002140d1e..f004251594 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -34,6 +34,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> 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; @@ -486,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/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 77abb45f2a..0a72287ba9 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs @@ -45,8 +45,10 @@ public interface IJellyfinDatabaseProvider Task RunScheduledOptimisation(CancellationToken cancellationToken); /// <summary> - /// If supported this should perform any actions that are required on stopping the jellyfin server, including the - /// same maintenance as <see cref="RunScheduledOptimisation(CancellationToken)"/>. + /// 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 f11cde7e48..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; @@ -117,17 +118,32 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider /// <inheritdoc/> public async Task RunShutdownTask(CancellationToken cancellationToken) { - // Run before disposing the application + // 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 { - await OptimizeAsync(cancellationToken).ConfigureAwait(false); + 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 optimization only costs performance, so never fail the shutdown over this. - _logger.LogError(ex, "Error while optimizing jellyfin.db"); + // 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(); } @@ -141,15 +157,72 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider 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("VACUUM", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); - _logger.LogInformation("jellyfin.db optimized successfully!"); + 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); + } } } + 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/> public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { @@ -157,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 /> @@ -183,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 4cdff055f4..b8d40614d2 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -556,6 +556,13 @@ public class SkiaEncoder : IImageEncoder /// <returns>The resized image.</returns> internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false) { + 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(); @@ -565,7 +572,7 @@ 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; @@ -576,7 +583,10 @@ public class SkiaEncoder : IImageEncoder samplingOptions, paint); - SharpenInPlace(target); + if (isDownscale) + { + SharpenInPlace(target); + } return SKImage.FromBitmap(target); } 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/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.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.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/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.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 248b236df8..d3b82cefcd 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -677,6 +677,8 @@ namespace Jellyfin.Providers.Tests.Manager 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; } 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/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs index 8926a7b13c..03bad3555e 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -105,6 +105,19 @@ namespace Jellyfin.Providers.Tests.Tmdb } [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) { 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/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/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 index 91148501ce..039693c432 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs @@ -104,6 +104,50 @@ public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture } [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"); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs index 0958512b1a..a9548a6d13 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs @@ -32,6 +32,8 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture 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() { @@ -68,8 +70,29 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture [Fact] public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() { - Assert.Equal(new HashSet<Guid> { _seriesPlayedViaAlternate }, Ids(BaseItemKind.Series, isPlayed: true)); - Assert.Equal(new HashSet<Guid> { _unplayedSeries }, Ids(BaseItemKind.Series, isPlayed: false)); + 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) @@ -95,6 +118,9 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture 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(); } @@ -113,7 +139,33 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture { var episodeId = Guid.NewGuid(); - context.BaseItems.Add(new BaseItemEntity + 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, @@ -123,12 +175,6 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture IsFolder = true }); - 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); - } - private void AddItem(JellyfinDbContext context, Guid id, string type, string name) => context.BaseItems.Add(new BaseItemEntity { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index ff683dc57a..787bb24150 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -201,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(); 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/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 cfc9c9496c..6da176b4f1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs @@ -11,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; @@ -26,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; @@ -35,6 +36,7 @@ public abstract class SqliteDbTestFixture : IDisposable _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() .UseSqlite(_connection) + .AddInterceptors(interceptors) .Options; using var context = CreateDbContext(); 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 index 297f930205..421671b520 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs @@ -10,6 +10,7 @@ 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; @@ -26,9 +27,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library; /// </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(); @@ -36,10 +42,19 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture 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()) { @@ -53,7 +68,7 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture CreateDbContextFactory(), CreateBaseItemRepository(itemTypeLookup), serverConfigurationManager.Object, - new Mock<ILibraryManager>().Object); + _libraryManager.Object); } [Fact] @@ -80,10 +95,52 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture Assert.DoesNotContain(_sourceAlternate, items); } - private async Task<List<Guid>> GetSimilarItemsAsync() + [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 = _source, Name = "Source" }, + new Movie { Id = sourceId ?? _source, Name = "Source" }, new SimilarItemsQuery { User = _user, Limit = 10, DtoOptions = new DtoOptions() }, CancellationToken.None).ConfigureAwait(false); @@ -102,19 +159,52 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture 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); + 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(unrelated, other), + CreateMap(crossSource, crossLibrary), + CreateMap(crossLibraryPrimary, crossLibrary), + CreateMap(crossLibraryVersion, crossLibrary), + CreateMap(sameLibraryPrimary, crossLibrary), + CreateMap(sameLibraryVersion, crossLibrary)); context.SaveChanges(); } - private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId) + 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 { @@ -122,6 +212,8 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture Type = _movieTypeName, Name = name, SortName = name, + ParentId = libraryId, + TopParentId = libraryId, MediaType = "Video", IsMovie = true, IsFolder = false, 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/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/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs index b1221f6f71..ecd8fafe80 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs @@ -1,12 +1,17 @@ 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; @@ -55,11 +60,33 @@ public class SyncPlayManagerTests 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() + public ManagerHarness(long? groupWaitTimeout = null) { var userManager = new Mock<IUserManager>(); var libraryManager = new Mock<ILibraryManager>(); @@ -67,11 +94,26 @@ public class SyncPlayManagerTests 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); + libraryManager.Object) + { + GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout + }; Session = CreateSession("session-1"); } @@ -91,5 +133,17 @@ public class SyncPlayManagerTests 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 index 0cccd5d4ca..d3cbc9b8be 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; @@ -20,6 +21,128 @@ 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(); @@ -81,9 +204,131 @@ public class WaitingGroupStateTests 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 { - public GroupHarness() + private readonly ISessionManager _sessionManager; + private readonly Guid _userId; + + public GroupHarness(long? groupWaitTimeout = null) { var userManager = new Mock<IUserManager>(); var sessionManager = new Mock<ISessionManager>(); @@ -99,31 +344,29 @@ public class WaitingGroupStateTests 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); - - First = new SessionInfo(sessionManager.Object, NullLogger.Instance) - { - Id = "first", - UserId = user.Id, - UserName = "first" - }; - Second = new SessionInfo(sessionManager.Object, NullLogger.Instance) + libraryManager.Object) { - Id = "second", - UserId = user.Id, - UserName = "second" + 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); @@ -132,10 +375,48 @@ public class WaitingGroupStateTests 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/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/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); + } +} |
